From 05e9397226e3a35c6a65ff642747fc9165d5b0db Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 19 Jun 2026 12:48:16 -0400 Subject: [PATCH 1/6] Add isometric 8-direction sprite export (#724). Introduces ModelIsometricRenderer, qtmesh isometric CLI, and generate_isometric_sprites MCP tool for production isometric sprite atlases (rows=directions, cols=animation frames). Bump version to 3.7.0. Co-authored-by: Cursor --- .github/actions/qtmesh/action.yml | 2 +- CLAUDE.md | 7 +- CMakeLists.txt | 2 +- README.md | 20 +- action.yml | 2 +- src/AppLaunchHandler.cpp | 2 +- src/CLIPipeline.cpp | 234 +++++++ src/CLIPipeline.h | 2 + ...CLIPipeline_cmdisometric_coverage_test.cpp | 196 ++++++ src/CLIPipeline_test.cpp | 37 ++ src/CMakeLists.txt | 2 + src/MCPServer.cpp | 139 ++++ src/MCPServer.h | 2 + src/ModelIsometricRenderer.cpp | 596 ++++++++++++++++++ src/ModelIsometricRenderer.h | 49 ++ src/ModelIsometricRenderer_test.cpp | 137 ++++ tests/CMakeLists.txt | 2 + website/src/App.jsx | 1 + website/src/DocsApp.jsx | 21 + website/src/data/content.js | 1 + website/src/hooks/useQtmeshActionRef.js | 2 +- 21 files changed, 1439 insertions(+), 17 deletions(-) create mode 100644 src/CLIPipeline_cmdisometric_coverage_test.cpp create mode 100644 src/ModelIsometricRenderer.cpp create mode 100644 src/ModelIsometricRenderer.h create mode 100644 src/ModelIsometricRenderer_test.cpp diff --git a/.github/actions/qtmesh/action.yml b/.github/actions/qtmesh/action.yml index 45725f5b3..60e0a3782 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, turntable, scan, material, optimize, …' + description: 'Subcommand: info, fix, convert, anim, validate, lod, pose, turntable, isometric, scan, material, optimize, …' required: true input-file: description: 'Directory or file to scan (relative to workspace). Defaults to .' diff --git a/CLAUDE.md b/CLAUDE.md index 2883b54e7..f7234360e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,8 @@ qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl # export singl qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # export N evenly spaced frames qtmesh turntable model.fbx -o turntable.png # PNG sprite sheet (12 frames default) qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-height 25 +qtmesh isometric model.fbx -o iso.png # 8-direction static sprite grid (rows=directions) +qtmesh isometric model.fbx --animation "Walk" --frames 8 -o iso.png # 8×8 animated atlas 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 @@ -97,7 +99,7 @@ qtmesh uv model.fbx --unwrap -o unwrapped.glb # xatlas auto-UV unwrap (#400). N qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb # write into UV1 (lightmap workflow) ``` -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`, `uv`, `retopo`, `skin`) 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`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`) 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. @@ -200,7 +202,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`, `turntable`, `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`, `isometric`, `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. @@ -244,6 +246,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%. - **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering. - **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`. +- **Isometric sprite export** (`src/ModelIsometricRenderer.h/cpp`, epic #724): headless RTT renderer for 8-direction (configurable) isometric sprite atlases. Reuses the turntable's offscreen capture pattern (RTSS materials, stable orbit framing from rest bounds, single camera re-placed per direction). Outer loop = compass directions (row 0 = front/+Z, clockwise from above); inner loop = evenly spaced animation frames via `AnimationState::setTimePosition` + `_updateAnimation` before readback. Grid layout: rows = directions, columns = frames. Surfaced via `qtmesh isometric`, MCP `generate_isometric_sprites`. Sentry breadcrumb categories `cli.isometric` / `ai.tool_call`. - **FBX LOD export gotcha**: `FBXExporter` prefers the cached `qtme.faces.` n-gon binding (set up by quad-migration #326) over `SubMesh::indexData`. The CLI `lod` per-LOD export path in `CLIPipeline::cmdLod` temporarily erases those bindings (and restores them after) so the swapped-in LOD indices actually reach the wire. If you add another LOD-export entry point, mirror that erase/restore pair. ## Development Guidelines diff --git a/CMakeLists.txt b/CMakeLists.txt index b5b7e7642..b618e0ecc 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 3.6.0 LANGUAGES C CXX) +project(QtMeshEditor VERSION 3.7.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/README.md b/README.md index b44690f3d..c26bac28e 100755 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ jobs: - uses: actions/checkout@v4 - name: Run QtMesh scan - uses: fernandotonon/QtMeshEditor@3.6.0 + uses: fernandotonon/QtMeshEditor@3.7.0 with: command: scan - image-tag: "3.6.0" + image-tag: "3.7.0" env: QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }} ``` @@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/ ```yaml # Validate a specific mesh -- uses: fernandotonon/QtMeshEditor@3.6.0 +- uses: fernandotonon/QtMeshEditor@3.7.0 with: command: validate input-file: ./models/character.fbx - image-tag: "3.6.0" + image-tag: "3.7.0" # Convert FBX → glTF -- uses: fernandotonon/QtMeshEditor@3.6.0 +- uses: fernandotonon/QtMeshEditor@3.7.0 with: command: convert input-file: ./models/character.fbx output-file: ./output/character.gltf2 - image-tag: "3.6.0" + image-tag: "3.7.0" # Resample Mixamo animations (200+ keyframes → 30) -- uses: fernandotonon/QtMeshEditor@3.6.0 +- uses: fernandotonon/QtMeshEditor@3.7.0 with: command: anim input-file: ./animations/dance.fbx output-file: ./output/dance_optimized.fbx options: --resample 30 - image-tag: "3.6.0" + image-tag: "3.7.0" # Get mesh info as JSON -- uses: fernandotonon/QtMeshEditor@3.6.0 +- uses: fernandotonon/QtMeshEditor@3.7.0 id: info with: command: info input-file: ./models/character.fbx options: --json - image-tag: "3.6.0" + image-tag: "3.7.0" # Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref) docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error diff --git a/action.yml b/action.yml index c44c3dac5..00cc0a3c8 100644 --- a/action.yml +++ b/action.yml @@ -8,7 +8,7 @@ branding: inputs: command: - description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose, turntable' + description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose, turntable, isometric' required: true input-file: description: 'Directory or file to scan (relative to workspace). Defaults to . (workspace root).' diff --git a/src/AppLaunchHandler.cpp b/src/AppLaunchHandler.cpp index 39f40949d..842f9e145 100644 --- a/src/AppLaunchHandler.cpp +++ b/src/AppLaunchHandler.cpp @@ -17,7 +17,7 @@ bool isCliSubcommand(const QString& arg) static const QStringList kSubcommands = { QStringLiteral("info"), QStringLiteral("fix"), QStringLiteral("convert"), QStringLiteral("anim"), QStringLiteral("validate"), QStringLiteral("lod"), - QStringLiteral("pose"), QStringLiteral("turntable"), QStringLiteral("scan"), + QStringLiteral("pose"), QStringLiteral("turntable"), QStringLiteral("isometric"), QStringLiteral("scan"), QStringLiteral("material"), QStringLiteral("pack-textures"), QStringLiteral("normal-from-height"), QStringLiteral("memory"), QStringLiteral("analyze"), QStringLiteral("vertex-cache"), diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 6f360c70d..07de01a29 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -32,6 +32,7 @@ #include "MorphAnimationManager.h" #include "NodeAnimationManager.h" #include "PoseLibrary.h" +#include "ModelIsometricRenderer.h" #include "ModelTurntableRenderer.h" #include "QtMeshCloudClient.h" #ifdef ENABLE_STABLE_DIFFUSION @@ -581,6 +582,11 @@ void CLIPipeline::printUsage() " turntable -o Use %02d in -o to write separate frame PNGs\n" " Options: --axis y|x|z, --elevation/--camera-height ,\n" " --width/--height, --json\n" + " isometric -o [--directions N] [--frames N] [--animation NAME]\n" + " 8-direction isometric sprite grid (rows=directions,\n" + " cols=animation frames). Static mesh when no animation.\n" + " Options: --elevation/--camera-height , --size WxH,\n" + " --width/--height, --start-azimuth , --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" @@ -1294,6 +1300,7 @@ int CLIPipeline::run(int argc, char* 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 == "isometric") rc = cmdIsometric(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); @@ -3281,6 +3288,233 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) return 0; } +int CLIPipeline::cmdIsometric(int argc, char* argv[]) +{ + // isometric -o [--directions N] [--frames N] [--animation NAME] + // [--size WxH] [--width W] [--height H] [--elevation deg] + // [--start-azimuth deg] [--json] + QString inputPath, outputPath, animationName; + int frameCount = 1; + bool frameCountExplicit = false; + int directionCount = 8; + int width = 512; + int height = 512; + float elevation = 30.0f; + float startAzimuth = 0.0f; + bool jsonOutput = false; + + for (int i = 1; i < argc; ++i) { + QString arg(argv[i]); + if (arg == "isometric" || arg == "--cli") + continue; + if (arg == "--json") { + jsonOutput = true; + continue; + } + if (arg == "-o" && i + 1 < argc) { + outputPath = QString(argv[++i]); + continue; + } + if (arg == "--animation" && i + 1 < argc) { + animationName = QString(argv[++i]); + continue; + } + if (arg == "--frames" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &frameCount)) { + err() << "Error: Invalid value for --frames." << Qt::endl; + return 2; + } + frameCountExplicit = true; + continue; + } + if (arg == "--directions" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &directionCount)) { + err() << "Error: Invalid value for --directions." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--width" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &width)) { + err() << "Error: Invalid value for --width." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--height" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &height)) { + err() << "Error: Invalid value for --height." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--size" && i + 1 < argc) { + const QString sizeArg = QString(argv[++i]); + const int xPos = sizeArg.indexOf(QLatin1Char('x')); + if (xPos > 0) { + if (!parseCliInt(sizeArg.left(xPos), &width) || !parseCliInt(sizeArg.mid(xPos + 1), &height)) { + err() << "Error: Invalid value for --size (expected WxH)." << Qt::endl; + return 2; + } + } else if (!parseCliInt(sizeArg, &width)) { + err() << "Error: Invalid value for --size." << Qt::endl; + return 2; + } else { + height = width; + } + continue; + } + if (arg == "--elevation" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &elevation)) { + err() << "Error: Invalid value for --elevation." << Qt::endl; + return 2; + } + continue; + } + if ((arg == "--camera-height" || arg == "--camera_height") && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &elevation)) { + err() << "Error: Invalid value for --camera-height." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--start-azimuth" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &startAzimuth)) { + err() << "Error: Invalid value for --start-azimuth." << Qt::endl; + return 2; + } + continue; + } + if (!arg.startsWith(QLatin1Char('-')) && inputPath.isEmpty()) { + inputPath = arg; + continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh isometric -o [--directions N] [--frames N]" << Qt::endl; + return 2; + } + if (outputPath.isEmpty()) { + err() << "Error: Output path required (-o)." << Qt::endl; + err() << "Usage: qtmesh isometric -o [--directions 8]" << Qt::endl; + return 2; + } + + if (!animationName.isEmpty() && !frameCountExplicit) + frameCount = 8; + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: File not found: " << inputPath << Qt::endl; + return 1; + } + + if (!initOgreHeadless()) + return 1; + + SentryReporter::addBreadcrumb("ui.action", + QString("Isometric .%1 dirs=%2 frames=%3 anim=%4") + .arg(fi.suffix()) + .arg(directionCount) + .arg(frameCount) + .arg(animationName.isEmpty() ? QStringLiteral("static") : animationName)); + SentryReporter::addBreadcrumb("file.import", fi.absoluteFilePath()); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + + QList entityList; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entityList.append(static_cast(obj)); + } + if (entityList.isEmpty()) { + SentryReporter::captureMessage(QString("CLI isometric: import failed (.%1)").arg(fi.suffix()), + "error"); + err() << "Error: Failed to load file: " << inputPath << Qt::endl; + return 1; + } + + Ogre::Entity *animatedEntity = nullptr; + if (!animationName.isEmpty()) { + for (Ogre::Entity *entity : entityList) { + if (entity && entity->hasSkeleton()) { + animatedEntity = entity; + break; + } + } + if (!animatedEntity) { + err() << "Error: --animation requires a skinned mesh with a skeleton." << Qt::endl; + return 1; + } + } + + IsometricOptions options; + options.width = width; + options.height = height; + options.elevationDegrees = elevation; + options.directionCount = qBound(1, directionCount, 64); + options.startAzimuthDegrees = startAzimuth; + + QList> grid; + QString renderError; + if (!ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, animationName, frameCount, options, + &grid, &renderError)) { + ModelIsometricRenderer::shutdown(); + err() << "Error: " << renderError << Qt::endl; + if (renderError.contains(QStringLiteral("not found"))) { + err() << "Available animations:" << Qt::endl; + if (animatedEntity) { + Ogre::SkeletonPtr skel = animatedEntity->getMesh()->getSkeleton(); + if (skel) { + for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) + err() << " " << QString::fromStdString(skel->getAnimation(ai)->getName()) << Qt::endl; + } + } + } + return 1; + } + + const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); + if (sheet.isNull() || !sheet.save(outputPath)) { + ModelIsometricRenderer::shutdown(); + err() << "Error: Failed to write isometric sprite sheet " << outputPath << Qt::endl; + return 1; + } + + ModelIsometricRenderer::shutdown(); + SentryReporter::addBreadcrumb("file.export", QFileInfo(outputPath).absoluteFilePath()); + + const int dirs = grid.size(); + const int frames = dirs > 0 ? grid.first().size() : 0; + + if (jsonOutput) { + QJsonObject root; + root["input"] = fi.absoluteFilePath(); + root["output"] = QFileInfo(outputPath).absoluteFilePath(); + root["directions"] = dirs; + root["frames"] = frames; + root["cellWidth"] = width; + root["cellHeight"] = height; + root["sheetWidth"] = sheet.width(); + root["sheetHeight"] = sheet.height(); + root["elevation"] = elevation; + root["startAzimuth"] = startAzimuth; + root["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); + if (!animationName.isEmpty()) + root["animation"] = animationName; + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)) + "\n"); + } else { + cliWrite(QString("Wrote isometric sprite sheet (%1 directions × %2 frames): %3\n") + .arg(dirs) + .arg(frames) + .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 c5220f006..0808421f5 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -89,6 +89,8 @@ class CLIPipeline { 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[]); + /// Render an 8-direction isometric sprite grid (rows = directions, cols = frames) (#724). + static int cmdIsometric(int argc, char* argv[]); static int cmdScan(int argc, char* argv[]); static int cmdMaterial(int argc, char* argv[]); /// #403: depth-conditioned (ControlNet) mesh-aware texture generation, diff --git a/src/CLIPipeline_cmdisometric_coverage_test.cpp b/src/CLIPipeline_cmdisometric_coverage_test.cpp new file mode 100644 index 000000000..83a3e44f6 --- /dev/null +++ b/src/CLIPipeline_cmdisometric_coverage_test.cpp @@ -0,0 +1,196 @@ +// Coverage tests for CLIPipeline::cmdIsometric — end-to-end render path with +// real on-disk mesh assets (robot.mesh fallback to generated cube .obj). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CLIPipeline.h" +#include "MeshImporterExporter.h" +#include "Manager.h" +#include "ModelIsometricRenderer.h" +#include "TestHelpers.h" + +namespace { + +class ArgvBuilder { +public: + explicit ArgvBuilder(const QStringList &args) + { + for (const QString &a : args) + m_storage.push_back(a.toUtf8()); + for (auto &ba : m_storage) + m_argv.push_back(ba.data()); + m_argc = static_cast(m_argv.size()); + } + int argc() { return m_argc; } + char **argv() { return m_argv.data(); } + +private: + std::vector m_storage; + std::vector m_argv; + int m_argc = 0; +}; + +QString writeCubeObj(const QString &dirPath, const QString &fileName) +{ + const QString path = QDir(dirPath).filePath(fileName); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) + return QString(); + f.write( + "o Cube\n" + "v -1 -1 -1\n" + "v 1 -1 -1\n" + "v 1 1 -1\n" + "v -1 1 -1\n" + "v -1 -1 1\n" + "v 1 -1 1\n" + "v 1 1 1\n" + "v -1 1 1\n" + "f 1 2 3\n" + "f 1 3 4\n" + "f 5 6 7\n" + "f 5 7 8\n" + "f 1 2 6\n" + "f 1 6 5\n"); + f.close(); + return path; +} + +QString modelsDir() +{ +#ifdef QTMESH_UT_SOURCE_ROOT + return QDir(QString::fromUtf8(QTMESH_UT_SOURCE_ROOT)).filePath(QStringLiteral("media/models")); +#else + QDir dir(QCoreApplication::applicationDirPath()); + if (dir.cdUp() && dir.cdUp()) + return dir.absoluteFilePath(QStringLiteral("media/models")); + return QStringLiteral("./media/models"); +#endif +} + +QByteArray firstAnimNameForFile(const QString &filePath) +{ + if (!Manager::getSingletonPtr()) + return QByteArray(); + + MeshImporterExporter::importer({filePath}); + auto &entities = Manager::getSingleton()->getEntities(); + QByteArray name; + if (!entities.isEmpty() && entities.first()->hasSkeleton()) { + Ogre::SkeletonPtr skel = entities.first()->getMesh()->getSkeleton(); + if (skel && skel->getNumAnimations() > 0) + name = QString::fromStdString( + skel->getAnimation(static_cast(0))->getName()) + .toUtf8(); + } + + auto nodes = Manager::getSingleton()->getSceneNodes(); + for (auto *node : nodes) { + Manager::getSingleton()->destroyAllAttachedMovableObjects(node); + Manager::getSingleton()->destroySceneNode(node); + } + return name; +} + +class CLIPipelineCmdIsometricCoverageTest : public ::testing::Test { +protected: + void SetUp() override + { + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + createStandardOgreMaterials(); + ASSERT_TRUE(m_tmp.isValid()); + } + + QString meshInput(const QString &objFallbackName) + { + const QString robot = testRobotMeshPath(); + if (!robot.isEmpty() && QFile::exists(robot)) + return robot; + const QString obj = writeCubeObj(m_tmp.path(), objFallbackName); + EXPECT_FALSE(obj.isEmpty()); + return obj; + } + + QString outPath(const QString &name) const { return m_tmp.filePath(name); } + + QTemporaryDir m_tmp; +}; + +TEST_F(CLIPipelineCmdIsometricCoverageTest, StaticGridWritesPngOnDisk) +{ + const QString mesh = meshInput("iso_static.obj"); + const QString out = outPath("iso_static.png"); + + ArgvBuilder args({"qtmesh", "isometric", mesh, "-o", out, "--directions", "4", "--size", "40"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + + ASSERT_TRUE(QFile::exists(out)); + QImage img(out); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 40); + EXPECT_EQ(img.height(), 160); +} + +TEST_F(CLIPipelineCmdIsometricCoverageTest, JsonFlagStillWritesGridPng) +{ + const QString mesh = meshInput("iso_json.obj"); + const QString out = outPath("iso_json.png"); + + ArgvBuilder args({"qtmesh", "isometric", mesh, "-o", out, "--directions", "2", "--size", "32", "--json"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + + ASSERT_TRUE(QFile::exists(out)); + QImage img(out); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 32); + EXPECT_EQ(img.height(), 64); +} + +TEST_F(CLIPipelineCmdIsometricCoverageTest, ElevationAndStartAzimuthVariants) +{ + const QString mesh = meshInput("iso_elev.obj"); + const QString out = outPath("iso_elev.png"); + + ArgvBuilder args({"qtmesh", "isometric", mesh, "-o", out, "--directions", "2", "--size", "36", + "--elevation", "25", "--start-azimuth", "15"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + ASSERT_TRUE(QFile::exists(out)); + QImage img(out); + EXPECT_EQ(img.width(), 36); + EXPECT_EQ(img.height(), 72); +} + +TEST_F(CLIPipelineCmdIsometricCoverageTest, AnimatedGridWhenAssetAvailable) +{ + const QString fbx = modelsDir() + "/Twist Dance.fbx"; + if (!QFile::exists(fbx)) + GTEST_SKIP() << "Twist Dance.fbx not available"; + + const QByteArray animName = firstAnimNameForFile(fbx); + if (animName.isEmpty()) + GTEST_SKIP() << "No animation found in Twist Dance.fbx"; + + const QString out = outPath("iso_anim.png"); + ArgvBuilder args({"qtmesh", "isometric", fbx, "-o", out, "--animation", animName.constData(), "--frames", "4", + "--directions", "4", "--size", "32"}); + ASSERT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + + ASSERT_TRUE(QFile::exists(out)); + QImage img(out); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 128); + EXPECT_EQ(img.height(), 128); +} + +} // namespace diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index f319b03a7..45cbe891e 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -1109,6 +1109,43 @@ TEST_F(CLIPipelineCmdTest, CmdTurntable_MinimalObjSingleFrameWritesOnePng) EXPECT_EQ(img.height(), 24); } +TEST(CLIPipelineCmdIsometricError, MissingInputFile) +{ + TestArgv args({"qtmesh", "isometric", "-o", "out.png"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdIsometricError, MissingOutputPath) +{ + TestArgv args({"qtmesh", "isometric", "model.fbx"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdIsometricError, NonexistentInputFile) +{ + TestArgv args({"qtmesh", "isometric", "/nonexistent/path/model_xyz.obj", "-o", "out.png"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 1); +} + +TEST_F(CLIPipelineCmdTest, CmdIsometric_StaticGridWritesPng) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "iso_one.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("iso.png").toUtf8(); + + TestArgv args({"qtmesh", "isometric", meshArg.constData(), + "-o", outArg.constData(), + "--directions", "2", + "--size", "24"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 24); + EXPECT_EQ(img.height(), 48); +} + 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 127fd28db..3247b83ec 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -139,6 +139,7 @@ AssetBrowserController.cpp AssetScanController.cpp MaterialPreviewRenderer.cpp ModelTurntableRenderer.cpp +ModelIsometricRenderer.cpp EditableMesh.cpp EditModeController.cpp EditorModeController.cpp @@ -266,6 +267,7 @@ AssetBrowserController.h AssetScanController.h MaterialPreviewRenderer.h ModelTurntableRenderer.h +ModelIsometricRenderer.h EditableMesh.h EditModeController.h EditorModeController.h diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 5696da0df..ed5f557c7 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -37,6 +37,7 @@ #include "QuadRetopo.h" #include "SkinWeights.h" #include "MeshDepthRenderer.h" +#include "ModelIsometricRenderer.h" #ifdef ENABLE_STABLE_DIFFUSION #include "SDManager.h" #endif @@ -618,6 +619,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("pack_atlas"), &MCPServer::toolPackAtlas}, {QStringLiteral("apply_atlas"), &MCPServer::toolApplyAtlas}, {QStringLiteral("optimize_mesh"), &MCPServer::toolOptimizeMesh}, + {QStringLiteral("generate_isometric_sprites"), &MCPServer::toolGenerateIsometricSprites}, {QStringLiteral("bake_vat"), &MCPServer::toolBakeVat}, {QStringLiteral("list_morph_targets"), &MCPServer::toolListMorphTargets}, {QStringLiteral("set_morph_weight"), &MCPServer::toolSetMorphWeight}, @@ -4540,6 +4542,104 @@ QJsonObject MCPServer::toolOptimizeMesh(const QJsonObject &args) } } +QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) +{ + SentryReporter::addBreadcrumb("ai.tool_call", "generate_isometric_sprites"); + + const QString filePath = args.value("file").toString(); + const QString outputPath = args.value("output").toString(); + if (filePath.isEmpty() || outputPath.isEmpty()) + return makeErrorResult("Error: missing required 'file' and 'output' arguments"); + if (!QFileInfo::exists(filePath)) + return makeErrorResult(QString("Error: file not found: %1").arg(filePath)); + + if (!Ogre::Root::getSingletonPtr() || !Ogre::Root::getSingletonPtr()->getRenderSystem()) + return makeErrorResult("Ogre render system not initialized"); + + auto *mgr = Manager::getSingletonPtr(); + if (!mgr) + return makeErrorResult("Manager unavailable"); + + const QString animationName = args.value("animation").toString(); + int frameCount = 1; + if (args.contains("frames")) + frameCount = args.value("frames").toInt(1); + else if (!animationName.isEmpty()) + frameCount = 8; + + IsometricOptions options; + if (args.contains("width")) options.width = args.value("width").toInt(options.width); + if (args.contains("height")) options.height = args.value("height").toInt(options.height); + if (args.contains("elevation")) options.elevationDegrees = static_cast(args.value("elevation").toDouble(30.0)); + if (args.contains("directions")) options.directionCount = args.value("directions").toInt(8); + if (args.contains("start_azimuth")) options.startAzimuthDegrees = static_cast(args.value("start_azimuth").toDouble(0.0)); + + SentryReporter::addBreadcrumb("file.import", + QString("Isometric import %1").arg(QFileInfo(filePath).fileName())); + + TransientImportSession session(mgr); + if (QString err = session.runImporter(QFileInfo(filePath).absoluteFilePath()); !err.isEmpty()) + return makeErrorResult(err); + + const QList &imported = session.importedEntities(); + if (imported.isEmpty()) + return makeErrorResult(QString("Failed to load any entities from %1").arg(filePath)); + + QList entityList; + for (Ogre::Entity *e : imported) + if (e) + entityList.append(e); + + Ogre::Entity *animatedEntity = nullptr; + if (!animationName.isEmpty()) { + for (Ogre::Entity *entity : entityList) { + if (entity && entity->hasSkeleton()) { + animatedEntity = entity; + break; + } + } + if (!animatedEntity) + return makeErrorResult("--animation requires a skinned mesh with a skeleton"); + } + + QList> grid; + QString renderError; + if (!ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, animationName, frameCount, options, + &grid, &renderError)) { + ModelIsometricRenderer::shutdown(); + return makeErrorResult(QString("Isometric render failed: %1").arg(renderError)); + } + + const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); + ModelIsometricRenderer::shutdown(); + if (sheet.isNull() || !sheet.save(outputPath)) + return makeErrorResult(QString("Failed to write isometric sprite sheet: %1").arg(outputPath)); + + SentryReporter::addBreadcrumb("file.export", QFileInfo(outputPath).absoluteFilePath()); + + const int dirs = grid.size(); + const int frames = dirs > 0 ? grid.first().size() : 0; + + QJsonObject result = makeSuccessResult( + QString("Wrote isometric sprite sheet (%1 directions × %2 frames): %3") + .arg(dirs) + .arg(frames) + .arg(QFileInfo(outputPath).fileName())); + result["output"] = QFileInfo(outputPath).absoluteFilePath(); + result["directions"] = dirs; + result["frames"] = frames; + result["cellWidth"] = options.width; + result["cellHeight"] = options.height; + result["sheetWidth"] = sheet.width(); + result["sheetHeight"] = sheet.height(); + result["elevation"] = options.elevationDegrees; + result["startAzimuth"] = options.startAzimuthDegrees; + result["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); + if (!animationName.isEmpty()) + result["animation"] = animationName; + return result; +} + QJsonObject MCPServer::toolBakeVat(const QJsonObject &args) { SentryReporter::addBreadcrumb("ai.tool_call", "bake_vat"); @@ -6434,6 +6534,45 @@ QJsonArray MCPServer::buildToolsList() ); } + // generate_isometric_sprites (#724) + { + QJsonObject props; + props["file"] = QJsonObject{ + {"type", "string"}, + {"description", "Source mesh file (FBX / glTF / glb / DAE / OBJ / PLY / STL / .mesh)."}}; + props["output"] = QJsonObject{ + {"type", "string"}, + {"description", "Output PNG path for the directions × frames sprite atlas."}}; + props["animation"] = QJsonObject{ + {"type", "string"}, + {"description", "Optional animation name. When set, samples evenly spaced frames across the clip."}}; + props["frames"] = QJsonObject{ + {"type", "integer"}, + {"description", "Animation frame columns (default 8 when animation is set, else 1)."}}; + props["directions"] = QJsonObject{ + {"type", "integer"}, + {"description", "Compass direction rows (default 8)."}}; + props["elevation"] = QJsonObject{ + {"type", "number"}, + {"description", "Camera elevation in degrees above the orbit plane (default 30)."}}; + props["width"] = QJsonObject{{"type", "integer"}, {"description", "Per-cell width in pixels (default 512)."}}; + props["height"] = QJsonObject{{"type", "integer"}, {"description", "Per-cell height in pixels (default 512)."}}; + props["start_azimuth"] = QJsonObject{ + {"type", "number"}, + {"description", "Rotate row 0 to align with your game's facing direction (degrees, default 0)."}}; + QJsonArray required; + required.append("file"); + required.append("output"); + appendTool( + "generate_isometric_sprites", + "Render an isometric / 8-direction animated sprite atlas from a mesh file. Rows are fixed " + "compass directions (row 0 = front, clockwise from above); columns are evenly spaced " + "animation frames. Static mesh when `animation` is omitted. Same renderer as " + "`qtmesh isometric`. Returns output path, grid dimensions, and the direction-order convention.", + props, + required); + } + // cloud_status / cloud_login / cloud_logout / cloud_list_projects / cloud_delete_project / cloud_upload { appendTool( diff --git a/src/MCPServer.h b/src/MCPServer.h index 3ddc39ebe..155a38e2c 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -222,6 +222,8 @@ private slots: /// optimizations end-to-end on a single asset and writes the result. /// Per-stage applied/summary report on success. QJsonObject toolOptimizeMesh(const QJsonObject &args); + /// #724: 8-direction isometric animated sprite grid export (file-in / file-out). + QJsonObject toolGenerateIsometricSprites(const QJsonObject &args); /// Phase VAT slice 4: bake a skeletal animation to a Vertex /// Animation Texture + JSON sidecar. Args mirror the /// `qtmesh vat` CLI subcommand: file, anim, fps, encoding, diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp new file mode 100644 index 000000000..e0c5b902b --- /dev/null +++ b/src/ModelIsometricRenderer.cpp @@ -0,0 +1,596 @@ +#include "ModelIsometricRenderer.h" + +#include "GlobalDefinitions.h" +#include "Manager.h" +#include "MeshImporterExporter.h" +#include "RTShaderHelper.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +Ogre::SceneManager *sceneMgr() +{ + return Manager::getSingletonPtr() ? Manager::getSingleton()->getSceneMgr() : nullptr; +} + +struct IsometricState { + Ogre::SceneNode *pivotNode = nullptr; + Ogre::Camera *camera = nullptr; + Ogre::SceneNode *cameraNode = nullptr; + Ogre::Light *light = nullptr; + Ogre::SceneNode *lightNode = nullptr; + Ogre::TexturePtr rttTexture; + Ogre::RenderTarget *renderTarget = nullptr; + int rttWidth = 0; + int rttHeight = 0; + Ogre::ColourValue savedAmbient; + bool hasSavedAmbient = false; +}; + +IsometricState &state() +{ + static IsometricState 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 applyIsometricLighting(Ogre::SceneManager *sm) +{ + IsometricState &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("ModelIsometricLight"); + st.light->setType(Ogre::Light::LT_DIRECTIONAL); + st.lightNode = sm->getRootSceneNode()->createChildSceneNode("ModelIsometricLightNode"); + 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 restoreIsometricLighting(Ogre::SceneManager *sm) +{ + IsometricState &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; + } + + IsometricState &st = state(); + if (st.renderTarget && st.rttWidth == width && st.rttHeight == height) { + if (st.renderTarget->getNumViewports() > 0) { + Ogre::Viewport *vp = st.renderTarget->getViewport(0); + vp->setBackgroundColour(bg); + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + } + return true; + } + + ModelIsometricRenderer::shutdown(); + + try { + st.rttTexture = Ogre::TextureManager::getSingleton().createManual( + "ModelIsometricRTT", 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("ModelIsometricCamera"); + st.camera->setNearClipDistance(0.01f); + st.camera->setFarClipDistance(100000.0f); + st.camera->setFOVy(Ogre::Degree(45.0f)); + st.pivotNode = sm->getRootSceneNode()->createChildSceneNode("ModelIsometricPivot"); + st.cameraNode = st.pivotNode->createChildSceneNode("ModelIsometricCameraNode"); + st.cameraNode->attachObject(st.camera); + } + + if (st.renderTarget->getNumViewports() == 0) { + Ogre::Viewport *vp = st.renderTarget->addViewport(st.camera); + vp->setClearEveryFrame(true); + vp->setBackgroundColour(bg); + vp->setOverlaysEnabled(false); + vp->setShadowsEnabled(true); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + } else { + Ogre::Viewport *vp = st.renderTarget->getViewport(0); + vp->setBackgroundColour(bg); + vp->setVisibilityMask(SCENE_VISIBILITY_FLAGS); + vp->setMaterialScheme(Ogre::MSN_SHADERGEN); + } + + const Ogre::Real aspect = + height > 0 ? static_cast(width) / static_cast(height) : 1.0f; + st.camera->setAspectRatio(aspect); + return true; + } catch (const Ogre::Exception &e) { + ModelIsometricRenderer::shutdown(); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + return false; + } catch (...) { + ModelIsometricRenderer::shutdown(); + if (errorOut) + *errorOut = QStringLiteral("Failed to create isometric render target"); + return false; + } +} + +void refreshEntityBounds(const QList &entities) +{ + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + if (Ogre::SceneNode *node = entity->getParentSceneNode()) + node->_update(true, true); + } +} + +Ogre::AxisAlignedBox combinedWorldBounds(const QList &entities) +{ + Ogre::AxisAlignedBox box; + box.setNull(); + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + box.merge(entity->getWorldBoundingBox(true)); + } + return box; +} + +void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisAlignedBox &bounds) +{ + if (bounds.isNull() || bounds.isInfinite()) + return; + + const Ogre::Vector3 center = bounds.getCenter(); + if (center.squaredLength() < 1e-10f) + return; + + std::unordered_set shifted; + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + Ogre::SceneNode *node = entity->getParentSceneNode(); + if (!node || !shifted.insert(node).second) + continue; + node->translate(-center, Ogre::Node::TS_WORLD); + } + + bounds.setExtents(bounds.getMinimum() - center, bounds.getMaximum() - center); + refreshEntityBounds(entities); +} + +Ogre::Vector3 orbitAxisVector(TurntableAxis axis) +{ + switch (axis) { + case TurntableAxis::X: + return Ogre::Vector3::UNIT_X; + case TurntableAxis::Z: + return Ogre::Vector3::UNIT_Z; + case TurntableAxis::Y: + default: + return Ogre::Vector3::UNIT_Y; + } +} + +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; +} + +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); + } +} + +void cameraAxesFromViewDir(const Ogre::Vector3 &viewDir, const Ogre::Vector3 &worldUp, Ogre::Vector3 &outSide, + Ogre::Vector3 &outUp) +{ + Ogre::Vector3 forward = viewDir; + if (forward.squaredLength() < 1e-8f) + forward = Ogre::Vector3(0.0f, 0.0f, 1.0f); + forward.normalise(); + + Ogre::Vector3 side = forward.crossProduct(worldUp); + if (side.squaredLength() < 1e-8f) + side = forward.crossProduct(Ogre::Vector3::UNIT_X); + side.normalise(); + outSide = side; + outUp = side.crossProduct(forward); + outUp.normalise(); +} + +Ogre::Real fitOrbitDistance(const Ogre::AxisAlignedBox &bounds, const Ogre::Vector3 &pivotPoint, + const Ogre::Vector3 &viewDir, Ogre::Camera *camera, float paddingFactor) +{ + const Ogre::Vector3 center = pivotPoint; + Ogre::Vector3 dir = viewDir; + if (dir.squaredLength() < 1e-8f) + dir = Ogre::Vector3(0.0f, 0.0f, 1.0f); + dir.normalise(); + + Ogre::Vector3 side; + Ogre::Vector3 up; + cameraAxesFromViewDir(dir, Ogre::Vector3::UNIT_Y, side, up); + + const Ogre::Radian fovY = camera->getFOVy(); + const Ogre::Real aspect = camera->getAspectRatio(); + const float tanHalfY = std::tan(fovY.valueRadians() * 0.5f); + const float tanHalfX = tanHalfY * aspect; + + const Ogre::Vector3 &bmin = bounds.getMinimum(); + const Ogre::Vector3 &bmax = bounds.getMaximum(); + Ogre::Real required = 0.1f; + for (int xi = 0; xi < 2; ++xi) { + for (int yi = 0; yi < 2; ++yi) { + for (int zi = 0; zi < 2; ++zi) { + const Ogre::Vector3 corner(xi ? bmax.x : bmin.x, yi ? bmax.y : bmin.y, zi ? bmax.z : bmin.z); + const Ogre::Vector3 rel = corner - center; + const float depthAlongView = rel.dotProduct(dir); + const float x = std::abs(rel.dotProduct(side)); + const float y = std::abs(rel.dotProduct(up)); + const float need = depthAlongView + std::max(x / tanHalfX, y / tanHalfY); + required = std::max(required, need); + } + } + } + return required * paddingFactor; +} + +void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, TurntableAxis axis, + float elevationRadians, float paddingFactor) +{ + IsometricState &st = state(); + if (!st.camera || !st.cameraNode || !st.pivotNode || bounds.isNull() || bounds.isInfinite()) + return; + + const Ogre::Vector3 pivotPoint = turntablePivotPoint(bounds); + + const float horizUnit = std::cos(elevationRadians); + const float axialUnit = std::sin(elevationRadians); + Ogre::Vector3 restOffset = cameraRestOffset(axis, horizUnit, axialUnit); + if (restOffset.squaredLength() < 1e-8f) + restOffset = cameraRestOffset(axis, 1.0f, 0.0f); + + Ogre::Vector3 localViewDir = -restOffset; + localViewDir.normalise(); + const Ogre::Quaternion orbitRot(Ogre::Radian(angleRadians), orbitAxisVector(axis)); + Ogre::Vector3 viewDir = orbitRot * localViewDir; + + const Ogre::Real distance = fitOrbitDistance(bounds, pivotPoint, viewDir, st.camera, paddingFactor); + const float horiz = distance * horizUnit; + const float axial = distance * axialUnit; + + st.pivotNode->setPosition(pivotPoint); + st.pivotNode->setOrientation(Ogre::Quaternion(Ogre::Radian(angleRadians), orbitAxisVector(axis))); + st.cameraNode->setPosition(cameraRestOffset(axis, horiz, axial)); + st.cameraNode->lookAt(Ogre::Vector3::ZERO, Ogre::Node::TS_PARENT); +} + +void prepareMaterialsForCapture(const QList &entities) +{ + std::unordered_set processed; + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + MeshImporterExporter::applyNormalMapsToEntity(entity); + for (unsigned int sub = 0; sub < entity->getNumSubEntities(); ++sub) { + Ogre::MaterialPtr mat = entity->getSubEntity(sub)->getMaterial(); + if (!mat) + continue; + if (!processed.insert(mat.get()).second) + continue; + RTShaderHelper::finalizeShaderGenMaterial(mat); + } + } +} + +QImage readRenderTarget(int width, int height) +{ + QImage image(width, height, QImage::Format_RGBA8888); + image.fill(Qt::transparent); + IsometricState &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; +} + +void applyAnimationFrame(Ogre::Entity *entity, Ogre::AnimationState *animState, float time) +{ + if (!entity || !animState) + return; + + animState->setEnabled(true); + animState->setTimePosition(time); + if (Ogre::AnimationStateSet *states = entity->getAllAnimationStates()) + states->_notifyDirty(); + + Ogre::FrameEvent ev{}; + ev.timeSinceLastFrame = 0.0f; + ev.timeSinceLastEvent = 0.0f; + if (Ogre::Root *root = Ogre::Root::getSingletonPtr()) + root->_fireFrameRenderingQueued(ev); + + entity->_updateAnimation(); +} + +} // namespace + +QString ModelIsometricRenderer::directionOrderConvention() +{ + return QStringLiteral( + "Row 0 = front (camera on +Z, model facing camera); each subsequent row rotates the " + "camera clockwise when viewed from above (+Y). Columns are evenly spaced animation frames " + "left-to-right."); +} + +void ModelIsometricRenderer::shutdown() +{ + IsometricState &st = state(); + auto *sm = sceneMgr(); + if (st.renderTarget) { + st.renderTarget->removeAllViewports(); + st.renderTarget = nullptr; + } + if (st.rttTexture) { + Ogre::TextureManager::getSingleton().remove(st.rttTexture); + st.rttTexture.reset(); + } + st.rttWidth = 0; + st.rttHeight = 0; + + if (sm) { + if (st.hasSavedAmbient) { + sm->setAmbientLight(st.savedAmbient); + st.hasSavedAmbient = false; + } + if (st.light) { + if (st.lightNode) { + st.lightNode->detachObject(st.light); + sm->destroySceneNode(st.lightNode); + } + sm->destroyLight(st.light); + } + if (st.camera) { + if (st.cameraNode) + st.cameraNode->detachObject(st.camera); + sm->destroyCamera(st.camera); + } + if (st.cameraNode) + sm->destroySceneNode(st.cameraNode); + if (st.pivotNode) + sm->destroySceneNode(st.pivotNode); + } + st.lightNode = nullptr; + st.light = nullptr; + st.camera = nullptr; + st.cameraNode = nullptr; + st.pivotNode = nullptr; +} + +bool ModelIsometricRenderer::renderToGrid(const QList &entities, Ogre::Entity *animatedEntity, + const QString &animationName, int frameCount, + const IsometricOptions &options, + QList> *outRowsByDirection, QString *errorOut) +{ + if (!outRowsByDirection) { + if (errorOut) + *errorOut = QStringLiteral("Output grid is null"); + return false; + } + outRowsByDirection->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 directions = std::clamp(options.directionCount, 1, 64); + const int frames = std::clamp(frameCount, 1, 360); + + const bool wantsAnimation = animatedEntity && !animationName.isEmpty(); + Ogre::AnimationState *animState = nullptr; + float animLength = 0.0f; + if (wantsAnimation) { + if (!animatedEntity->hasSkeleton()) { + if (errorOut) + *errorOut = QStringLiteral("Animated entity has no skeleton"); + return false; + } + Ogre::AnimationStateSet *states = animatedEntity->getAllAnimationStates(); + if (!states || !states->hasAnimationState(animationName.toStdString())) { + if (errorOut) + *errorOut = QStringLiteral("Animation '%1' not found").arg(animationName); + return false; + } + animState = states->getAnimationState(animationName.toStdString()); + animLength = animState->getLength(); + } + + if (!ensureRenderTarget(width, height, options.background, errorOut)) + return false; + + refreshEntityBounds(entities); + Ogre::AxisAlignedBox bounds = combinedWorldBounds(entities); + if (bounds.isNull() || bounds.isInfinite()) { + if (errorOut) + *errorOut = QStringLiteral("Could not compute model bounds"); + return false; + } + + recenterEntitiesAtOrigin(entities, bounds); + bounds = combinedWorldBounds(entities); + + const float elevationRad = + Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); + const float startAzimuthRad = Ogre::Degree(options.startAzimuthDegrees).valueRadians(); + const float directionStep = directions > 0 ? Ogre::Math::TWO_PI / static_cast(directions) : 0.0f; + + prepareSceneForCapture(entities); + prepareMaterialsForCapture(entities); + applyIsometricLighting(sm); + + if (wantsAnimation) { + for (const auto &[key, as] : animatedEntity->getAllAnimationStates()->getAnimationStates()) { + Q_UNUSED(key); + if (as) + as->setEnabled(false); + } + } + + SentryReporter::addBreadcrumb( + "cli.isometric", + QStringLiteral("render start dirs=%1 frames=%2 animated=%3") + .arg(directions) + .arg(frames) + .arg(wantsAnimation ? animationName : QStringLiteral("static"))); + + outRowsByDirection->reserve(directions); + try { + for (int dir = 0; dir < directions; ++dir) { + const float azimuth = startAzimuthRad - static_cast(dir) * directionStep; + placeCameraOnAxis(bounds, azimuth, options.upAxis, elevationRad, 1.25f); + + QList row; + row.reserve(frames); + for (int frame = 0; frame < frames; ++frame) { + if (wantsAnimation) { + const float t = (frames == 1) ? 0.0f + : animLength * static_cast(frame) / static_cast(frames - 1); + applyAnimationFrame(animatedEntity, animState, t); + } + state().renderTarget->update(); + row.append(readRenderTarget(width, height)); + } + outRowsByDirection->append(row); + } + + if (wantsAnimation && animState) + animState->setEnabled(false); + + restoreIsometricLighting(sm); + SentryReporter::addBreadcrumb("cli.isometric", + QStringLiteral("render ok dirs=%1 frames=%2").arg(directions).arg(frames)); + return true; + } catch (const Ogre::Exception &e) { + outRowsByDirection->clear(); + restoreIsometricLighting(sm); + if (errorOut) + *errorOut = QString::fromStdString(e.getFullDescription()); + SentryReporter::addBreadcrumb("cli.isometric", QStringLiteral("render failed: Ogre exception")); + return false; + } catch (...) { + outRowsByDirection->clear(); + restoreIsometricLighting(sm); + if (errorOut) + *errorOut = QStringLiteral("Isometric render failed"); + SentryReporter::addBreadcrumb("cli.isometric", QStringLiteral("render failed")); + return false; + } +} + +QImage ModelIsometricRenderer::composeDirectionGrid(const QList> &rowsByDirection) +{ + if (rowsByDirection.isEmpty()) + return {}; + + const int directionCount = rowsByDirection.size(); + int frameCount = 0; + int frameW = 0; + int frameH = 0; + for (const QList &row : rowsByDirection) { + if (row.isEmpty()) + return {}; + frameCount = std::max(frameCount, static_cast(row.size())); + if (frameW == 0) { + frameW = row.first().width(); + frameH = row.first().height(); + } + } + if (frameCount <= 0 || frameW <= 0 || frameH <= 0) + return {}; + + QImage sheet(frameCount * frameW, directionCount * frameH, QImage::Format_RGBA8888); + sheet.fill(Qt::transparent); + + QPainter painter(&sheet); + for (int dir = 0; dir < directionCount; ++dir) { + const QList &row = rowsByDirection.at(dir); + for (int frame = 0; frame < row.size(); ++frame) { + const QImage &src = row.at(frame); + if (src.width() != frameW || src.height() != frameH) + continue; + painter.drawImage(frame * frameW, dir * frameH, src); + } + } + return sheet; +} diff --git a/src/ModelIsometricRenderer.h b/src/ModelIsometricRenderer.h new file mode 100644 index 000000000..95b330917 --- /dev/null +++ b/src/ModelIsometricRenderer.h @@ -0,0 +1,49 @@ +#ifndef MODELISOMETRICRENDERER_H +#define MODELISOMETRICRENDERER_H + +#include "ModelTurntableRenderer.h" + +#include +#include +#include + +#include + +/** + * Headless Ogre render-to-texture isometric / 8-direction sprite export (#724). + * + * Renders a model from fixed compass directions while optionally sampling an + * animation into N frames per direction. Output layout: rows = directions, + * columns = animation frames (isometric game sprite atlas). + */ +struct IsometricOptions { + int width = 512; + int height = 512; + /// Camera angle above the orbit plane, in degrees (~30° isometric default). + float elevationDegrees = 30.0f; + TurntableAxis upAxis = TurntableAxis::Y; + Ogre::ColourValue background{0.12f, 0.12f, 0.13f, 1.0f}; + int directionCount = 8; + /// Align row 0 to the game's facing direction (degrees). + float startAzimuthDegrees = 0.0f; +}; + +class ModelIsometricRenderer +{ +public: + /// Human-readable direction row order for CLI/MCP reports. + static QString directionOrderConvention(); + + /// Render a directions × frames grid. Returns false on failure. + static bool renderToGrid(const QList &entities, Ogre::Entity *animatedEntity, + const QString &animationName, int frameCount, const IsometricOptions &options, + QList> *outRowsByDirection, QString *errorOut = nullptr); + + /// Lay out rows (directions) × columns (frames) into a single atlas image. + static QImage composeDirectionGrid(const QList> &rowsByDirection); + + /// Destroy isometric camera / RTT resources (safe to call repeatedly). + static void shutdown(); +}; + +#endif // MODELISOMETRICRENDERER_H diff --git a/src/ModelIsometricRenderer_test.cpp b/src/ModelIsometricRenderer_test.cpp new file mode 100644 index 000000000..c5896fa64 --- /dev/null +++ b/src/ModelIsometricRenderer_test.cpp @@ -0,0 +1,137 @@ +#include + +#include "Manager.h" +#include "ModelIsometricRenderer.h" +#include "PrimitiveObject.h" +#include "TestHelpers.h" + +#include +#include + +class ModelIsometricRendererTest : public ::testing::Test { +protected: + void SetUp() override + { + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + ModelIsometricRenderer::shutdown(); + } + + void TearDown() override { ModelIsometricRenderer::shutdown(); } +}; + +TEST_F(ModelIsometricRendererTest, RejectsEmptyEntityList) +{ + QList> grid; + QString err; + EXPECT_FALSE(ModelIsometricRenderer::renderToGrid({}, nullptr, {}, 1, IsometricOptions{}, &grid, &err)); + EXPECT_FALSE(err.isEmpty()); + EXPECT_TRUE(grid.isEmpty()); +} + +TEST_F(ModelIsometricRendererTest, RejectsNullBoundsWhenEntitiesAreNull) +{ + QList> grid; + QString err; + + QList entities; + entities.append(nullptr); + + EXPECT_FALSE(ModelIsometricRenderer::renderToGrid(entities, nullptr, {}, 1, IsometricOptions{}, &grid, &err)); + EXPECT_FALSE(err.isEmpty()); + EXPECT_TRUE(grid.isEmpty()); +} + +TEST_F(ModelIsometricRendererTest, ClampsMinimumSizeAndDirectionCount) +{ + PrimitiveObject::createCube(QStringLiteral("IsoMinClampCube")); + + QList entities; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + ASSERT_FALSE(entities.isEmpty()); + + IsometricOptions options; + options.width = 1; + options.height = 1; + options.directionCount = 0; + + QList> grid; + QString err; + ASSERT_TRUE(ModelIsometricRenderer::renderToGrid(entities, nullptr, {}, 1, options, &grid, &err)) << err.toStdString(); + ASSERT_EQ(grid.size(), 1); + ASSERT_EQ(grid.first().size(), 1); + EXPECT_EQ(grid.first().first().width(), 16); + EXPECT_EQ(grid.first().first().height(), 16); +} + +TEST_F(ModelIsometricRendererTest, StaticGridDimensions) +{ + PrimitiveObject::createSphere(QStringLiteral("IsoTestSphere")); + + QList entities; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + ASSERT_FALSE(entities.isEmpty()); + + IsometricOptions options; + options.width = 64; + options.height = 48; + options.directionCount = 4; + + QList> grid; + QString err; + ASSERT_TRUE(ModelIsometricRenderer::renderToGrid(entities, nullptr, {}, 1, options, &grid, &err)) << err.toStdString(); + ASSERT_EQ(grid.size(), 4); + for (const QList &row : grid) { + ASSERT_EQ(row.size(), 1); + EXPECT_EQ(row.first().width(), 64); + EXPECT_EQ(row.first().height(), 48); + } + + const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); + EXPECT_EQ(sheet.width(), 64); + EXPECT_EQ(sheet.height(), 48 * 4); +} + +TEST_F(ModelIsometricRendererTest, ComposeDirectionGrid) +{ + QList> grid; + for (int dir = 0; dir < 2; ++dir) { + QList row; + for (int frame = 0; frame < 3; ++frame) + row << QImage(10, 8, QImage::Format_RGBA8888); + grid << row; + } + const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); + EXPECT_EQ(sheet.width(), 30); + EXPECT_EQ(sheet.height(), 16); +} + +TEST(ModelIsometricDirectionOrderStatic, DescribesRowZeroFront) +{ + EXPECT_FALSE(ModelIsometricRenderer::directionOrderConvention().isEmpty()); + EXPECT_TRUE(ModelIsometricRenderer::directionOrderConvention().contains(QStringLiteral("Row 0"))); +} + +TEST_F(ModelIsometricRendererTest, RejectsNullOutputGrid) +{ + QString err; + QList entities; + EXPECT_FALSE(ModelIsometricRenderer::renderToGrid(entities, nullptr, {}, 1, IsometricOptions{}, nullptr, &err)); + EXPECT_FALSE(err.isEmpty()); +} + +TEST_F(ModelIsometricRendererTest, ShutdownIsIdempotent) +{ + ModelIsometricRenderer::shutdown(); + ModelIsometricRenderer::shutdown(); +} + +TEST(ModelIsometricComposeGridStatic, EmptyReturnsNullImage) +{ + EXPECT_TRUE(ModelIsometricRenderer::composeDirectionGrid({}).isNull()); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 864b0ab67..8f926f177 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -148,6 +148,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetScanController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelTurntableRenderer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelIsometricRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorModeController.cpp @@ -286,6 +287,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/AssetScanController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialPreviewRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelTurntableRenderer.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ModelIsometricRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorModeController.h diff --git a/website/src/App.jsx b/website/src/App.jsx index b14a1718d..963c3e3b1 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -125,6 +125,7 @@ function App() { { 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: 'isometric', label: 'Isometric', title: '8-direction sprite atlas', code: pipelineExamples.isometric, language: 'isometric' }, { id: 'vat', label: 'VAT', title: 'Bake to vertex animation texture', code: pipelineExamples.vat, language: 'bash' }, { 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 b21a87aa9..0c1f3bda4 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -18,6 +18,7 @@ const NAV = [ { id: 'cmd-lod', label: 'lod' }, { id: 'cmd-pose', label: 'pose' }, { id: 'cmd-turntable', label: 'turntable' }, + { id: 'cmd-isometric', label: 'isometric' }, { id: 'cmd-vat', label: 'vat' }, { id: 'cmd-scan', label: 'scan' }, ]}, @@ -478,6 +479,26 @@ qtmesh turntable -o frame_%02d.png [--frames N] [--axis y|x|z]`}

+ Headless render-to-texture export for isometric / 8-direction sprite atlases used by 2D games (Godot AnimatedSprite2D, Unity sliced sheets, etc.). Renders the model from fixed compass directions while optionally sampling an animation into evenly spaced frames. Output is a single PNG grid: rows = directions, columns = animation frames. Row 0 is the front view (camera on +Z); each subsequent row rotates clockwise when viewed from above (+Y). Uses the same Ogre RTSS path as turntable.} + synopsis={`qtmesh isometric -o [--directions N] [--frames N] [--animation NAME]`} + options={[ + ['-o ', 'Output PNG path (required)'], + ['--directions N', 'Compass direction rows (default 8; clamped 1–64)'], + ['--frames N', 'Animation frame columns (default 1 static, 8 when --animation is set)'], + ['--animation NAME', 'Sample this skeletal animation across --frames columns'], + ['--elevation DEG', 'Camera elevation above the orbit plane (default 30; alias --camera-height)'], + ['--start-azimuth DEG', 'Rotate row 0 to match your game facing (default 0)'], + ['--size WxH', 'Per-cell resolution (default 512×512)'], + ['--width W / --height H', 'Per-cell dimensions'], + ['--json', 'Emit machine-readable report (grid dims, direction order, paths)'], + ]} + examples={[ + 'qtmesh isometric character.fbx -o iso.png', + 'qtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_walk.png --size 256', + 'qtmesh isometric prop.glb -o iso_prop.png --directions 4 --elevation 25 --json', + ]} + /> + Bake a skeletal animation to a Vertex Animation Texture (OpenVAT format). The output is a 16-bit PNG storing per-vertex position+normal samples in time, a JSON sidecar with the playback bounds, a vertex-order-aligned glTF, and an Ogre bind sidecar ({`_ogre_bind.bin`}) so engine importers can realign UV2 to the bake's column order on import. Drop-in shader templates for Godot/Unity/Unreal ship at tools/vat-shaders/ and can be copied next to the bake with --include-shaders. Live demos: Godot (web), Unity sample project, Unreal sample project.} synopsis={`qtmesh vat --anim [--fps N] [-o ] [--include-shaders ] [--json]`} options={[ diff --git a/website/src/data/content.js b/website/src/data/content.js index 93152c3dd..e99d8ae72 100644 --- a/website/src/data/content.js +++ b/website/src/data/content.js @@ -108,6 +108,7 @@ export const pipelineExamples = { 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`, + isometric: `qtmesh isometric character.fbx -o iso.png --directions 8\nqtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_anim.png --size 256`, vat: `# Bake a skeletal animation to a Vertex Animation Texture (OpenVAT format).\n# Emits a 16-bit PNG, a JSON sidecar, a vertex-order-aligned glTF,\n# and an Ogre bind sidecar so engine importers (Godot/Unity/Unreal)\n# can realign UV2 to the bake's column order on import.\nqtmesh vat character.fbx --anim "Run" --fps 30 -o bakes/run/\n\n# Drop the engine glue code next to the bake — accepts a comma-separated\n# subset of {godot, unity, unreal} or "all".\nqtmesh vat character.fbx --anim "Dance" --include-shaders godot,unity`, 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 }}`, diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index 190f29bcd..0abc2248f 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.6.0'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.7.0'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; From a5a83b1c4a440f3972680f2a90f23cdc904f730b Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 19 Jun 2026 13:41:59 -0400 Subject: [PATCH 2/6] Add --resolution flag for isometric sprite cell size. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose square per-cell resolution on qtmesh isometric and generate_isometric_sprites (range 16–8192), alongside existing --size/--width/--height overrides. Co-authored-by: Cursor --- CLAUDE.md | 1 + src/CLIPipeline.cpp | 15 +++++++++-- ...CLIPipeline_cmdisometric_coverage_test.cpp | 2 +- src/CLIPipeline_test.cpp | 25 +++++++++++++++++++ src/MCPServer.cpp | 15 +++++++++-- website/src/DocsApp.jsx | 7 +++--- website/src/data/content.js | 2 +- 7 files changed, 58 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7234360e..1c96bb2ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # export N qtmesh turntable model.fbx -o turntable.png # PNG sprite sheet (12 frames default) qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-height 25 qtmesh isometric model.fbx -o iso.png # 8-direction static sprite grid (rows=directions) +qtmesh isometric model.fbx --resolution 256 -o iso.png # square 256px cells qtmesh isometric model.fbx --animation "Walk" --frames 8 -o iso.png # 8×8 animated atlas qtmesh validate model.fbx # validate mesh (exit 1 if errors found) qtmesh validate model.fbx --json # validation results as JSON diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 07de01a29..9a82581f4 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -586,7 +586,7 @@ void CLIPipeline::printUsage() " 8-direction isometric sprite grid (rows=directions,\n" " cols=animation frames). Static mesh when no animation.\n" " Options: --elevation/--camera-height , --size WxH,\n" - " --width/--height, --start-azimuth , --json\n" + " --resolution N, --width/--height, --start-azimuth , --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" @@ -3291,7 +3291,7 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) int CLIPipeline::cmdIsometric(int argc, char* argv[]) { // isometric -o [--directions N] [--frames N] [--animation NAME] - // [--size WxH] [--width W] [--height H] [--elevation deg] + // [--size WxH] [--resolution N] [--width W] [--height H] [--elevation deg] // [--start-azimuth deg] [--json] QString inputPath, outputPath, animationName; int frameCount = 1; @@ -3334,6 +3334,15 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) } continue; } + if (arg == "--resolution" && i + 1 < argc) { + int res = 0; + if (!parseCliInt(QString(argv[++i]), &res) || res < 16 || res > 8192) { + err() << "Error: --resolution must be an integer in [16..8192]." << Qt::endl; + return 2; + } + width = height = res; + continue; + } if (arg == "--width" && i + 1 < argc) { if (!parseCliInt(QString(argv[++i]), &width)) { err() << "Error: Invalid value for --width." << Qt::endl; @@ -3497,6 +3506,8 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) root["frames"] = frames; root["cellWidth"] = width; root["cellHeight"] = height; + if (width == height) + root["resolution"] = width; root["sheetWidth"] = sheet.width(); root["sheetHeight"] = sheet.height(); root["elevation"] = elevation; diff --git a/src/CLIPipeline_cmdisometric_coverage_test.cpp b/src/CLIPipeline_cmdisometric_coverage_test.cpp index 83a3e44f6..ffcb6fcf6 100644 --- a/src/CLIPipeline_cmdisometric_coverage_test.cpp +++ b/src/CLIPipeline_cmdisometric_coverage_test.cpp @@ -132,7 +132,7 @@ TEST_F(CLIPipelineCmdIsometricCoverageTest, StaticGridWritesPngOnDisk) const QString mesh = meshInput("iso_static.obj"); const QString out = outPath("iso_static.png"); - ArgvBuilder args({"qtmesh", "isometric", mesh, "-o", out, "--directions", "4", "--size", "40"}); + ArgvBuilder args({"qtmesh", "isometric", mesh, "-o", out, "--directions", "4", "--resolution", "40"}); EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); ASSERT_TRUE(QFile::exists(out)); diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 45cbe891e..c95416b46 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -1146,6 +1146,31 @@ TEST_F(CLIPipelineCmdTest, CmdIsometric_StaticGridWritesPng) EXPECT_EQ(img.height(), 48); } +TEST_F(CLIPipelineCmdTest, CmdIsometric_ResolutionSetsSquareCells) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "iso_res.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("iso_res.png").toUtf8(); + + TestArgv args({"qtmesh", "isometric", meshArg.constData(), + "-o", outArg.constData(), + "--directions", "4", + "--resolution", "32"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 32 * 1); + EXPECT_EQ(img.height(), 32 * 4); +} + +TEST(CLIPipelineCmdIsometricError, InvalidResolutionReturnsUsageError) +{ + TestArgv args({"qtmesh", "isometric", "model.fbx", "-o", "out.png", "--resolution", "8"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); +} + TEST(CLIPipelineCmdInfoError, NonexistentFile) { TestArgv args({"qtmesh", "info", "/tmp/nonexistent_cli_test_file_12345.fbx"}); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index ed5f557c7..2b7d39369 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4568,6 +4568,12 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) frameCount = 8; IsometricOptions options; + if (args.contains("resolution")) { + const int res = args.value("resolution").toInt(512); + if (res < 16 || res > 8192) + return makeErrorResult("Error: resolution must be an integer in [16..8192]"); + options.width = options.height = res; + } if (args.contains("width")) options.width = args.value("width").toInt(options.width); if (args.contains("height")) options.height = args.value("height").toInt(options.height); if (args.contains("elevation")) options.elevationDegrees = static_cast(args.value("elevation").toDouble(30.0)); @@ -4630,6 +4636,8 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) result["frames"] = frames; result["cellWidth"] = options.width; result["cellHeight"] = options.height; + if (options.width == options.height) + result["resolution"] = options.width; result["sheetWidth"] = sheet.width(); result["sheetHeight"] = sheet.height(); result["elevation"] = options.elevationDegrees; @@ -6555,8 +6563,11 @@ QJsonArray MCPServer::buildToolsList() props["elevation"] = QJsonObject{ {"type", "number"}, {"description", "Camera elevation in degrees above the orbit plane (default 30)."}}; - props["width"] = QJsonObject{{"type", "integer"}, {"description", "Per-cell width in pixels (default 512)."}}; - props["height"] = QJsonObject{{"type", "integer"}, {"description", "Per-cell height in pixels (default 512)."}}; + props["resolution"] = QJsonObject{ + {"type", "integer"}, + {"description", "Per-cell square resolution in pixels (sets width and height). Default 512. Range [16..8192]."}}; + props["width"] = QJsonObject{{"type", "integer"}, {"description", "Per-cell width in pixels (overrides resolution width). Default 512."}}; + props["height"] = QJsonObject{{"type", "integer"}, {"description", "Per-cell height in pixels (overrides resolution height). Default 512."}}; props["start_azimuth"] = QJsonObject{ {"type", "number"}, {"description", "Rotate row 0 to align with your game's facing direction (degrees, default 0)."}}; diff --git a/website/src/DocsApp.jsx b/website/src/DocsApp.jsx index 0c1f3bda4..3018ba586 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -488,14 +488,15 @@ qtmesh turntable -o frame_%02d.png [--frames N] [--axis y|x|z]`} ['--animation NAME', 'Sample this skeletal animation across --frames columns'], ['--elevation DEG', 'Camera elevation above the orbit plane (default 30; alias --camera-height)'], ['--start-azimuth DEG', 'Rotate row 0 to match your game facing (default 0)'], + ['--resolution N', 'Square per-cell resolution in pixels (default 512; range 16–8192)'], ['--size WxH', 'Per-cell resolution (default 512×512)'], - ['--width W / --height H', 'Per-cell dimensions'], + ['--width W / --height H', 'Per-cell dimensions (override --resolution)'], ['--json', 'Emit machine-readable report (grid dims, direction order, paths)'], ]} examples={[ 'qtmesh isometric character.fbx -o iso.png', - 'qtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_walk.png --size 256', - 'qtmesh isometric prop.glb -o iso_prop.png --directions 4 --elevation 25 --json', + 'qtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_walk.png --resolution 256', + 'qtmesh isometric prop.glb -o iso_prop.png --directions 4 --resolution 128 --json', ]} /> diff --git a/website/src/data/content.js b/website/src/data/content.js index e99d8ae72..1887f1e8d 100644 --- a/website/src/data/content.js +++ b/website/src/data/content.js @@ -108,7 +108,7 @@ export const pipelineExamples = { 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`, - isometric: `qtmesh isometric character.fbx -o iso.png --directions 8\nqtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_anim.png --size 256`, + isometric: `qtmesh isometric character.fbx -o iso.png --resolution 256\nqtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_anim.png --resolution 256`, vat: `# Bake a skeletal animation to a Vertex Animation Texture (OpenVAT format).\n# Emits a 16-bit PNG, a JSON sidecar, a vertex-order-aligned glTF,\n# and an Ogre bind sidecar so engine importers (Godot/Unity/Unreal)\n# can realign UV2 to the bake's column order on import.\nqtmesh vat character.fbx --anim "Run" --fps 30 -o bakes/run/\n\n# Drop the engine glue code next to the bake — accepts a comma-separated\n# subset of {godot, unity, unreal} or "all".\nqtmesh vat character.fbx --anim "Dance" --include-shaders godot,unity`, 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 }}`, From 29b017b884e02169db6f7c9bbc28b8b46443071a Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 19 Jun 2026 15:17:44 -0400 Subject: [PATCH 3/6] Add camera distance control for isometric sprite export. Expose fixed orbit distance and auto-fit padding via CLI, MCP, and IsometricOptions so sprite framing can be tuned without re-scaling the mesh. Co-authored-by: Cursor --- CLAUDE.md | 2 ++ src/CLIPipeline.cpp | 27 +++++++++++++++++-- ...CLIPipeline_cmdisometric_coverage_test.cpp | 26 +++++++++++++++--- src/CLIPipeline_test.cpp | 27 +++++++++++++++++++ src/MCPServer.cpp | 24 +++++++++++++++++ src/ModelIsometricRenderer.cpp | 9 ++++--- src/ModelIsometricRenderer.h | 4 +++ website/src/DocsApp.jsx | 3 +++ website/src/data/content.js | 2 +- 9 files changed, 114 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c96bb2ea..e275fcc91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,8 @@ qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-heigh qtmesh isometric model.fbx -o iso.png # 8-direction static sprite grid (rows=directions) qtmesh isometric model.fbx --resolution 256 -o iso.png # square 256px cells qtmesh isometric model.fbx --animation "Walk" --frames 8 -o iso.png # 8×8 animated atlas +qtmesh isometric model.fbx -o iso.png --padding 1.5 # zoom out (auto-fit × 1.5) +qtmesh isometric model.fbx -o iso.png --camera-distance 5 # fixed orbit distance 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/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 9a82581f4..5aaa8f17a 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -586,7 +586,8 @@ void CLIPipeline::printUsage() " 8-direction isometric sprite grid (rows=directions,\n" " cols=animation frames). Static mesh when no animation.\n" " Options: --elevation/--camera-height , --size WxH,\n" - " --resolution N, --width/--height, --start-azimuth , --json\n" + " --resolution N, --width/--height, --start-azimuth ,\n" + " --camera-distance N, --padding F, --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" @@ -3292,7 +3293,7 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) { // isometric -o [--directions N] [--frames N] [--animation NAME] // [--size WxH] [--resolution N] [--width W] [--height H] [--elevation deg] - // [--start-azimuth deg] [--json] + // [--start-azimuth deg] [--camera-distance N] [--padding F] [--json] QString inputPath, outputPath, animationName; int frameCount = 1; bool frameCountExplicit = false; @@ -3301,6 +3302,8 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) int height = 512; float elevation = 30.0f; float startAzimuth = 0.0f; + float cameraDistance = 0.0f; + float cameraPadding = 1.25f; bool jsonOutput = false; for (int i = 1; i < argc; ++i) { @@ -3394,6 +3397,20 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) } continue; } + if ((arg == "--camera-distance" || arg == "--camera_distance") && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &cameraDistance) || cameraDistance <= 0.0f) { + err() << "Error: --camera-distance must be a positive number." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--padding" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &cameraPadding) || cameraPadding <= 0.0f) { + err() << "Error: --padding must be a positive number." << Qt::endl; + return 2; + } + continue; + } if (!arg.startsWith(QLatin1Char('-')) && inputPath.isEmpty()) { inputPath = arg; continue; @@ -3465,6 +3482,8 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) options.elevationDegrees = elevation; options.directionCount = qBound(1, directionCount, 64); options.startAzimuthDegrees = startAzimuth; + options.cameraDistance = cameraDistance; + options.cameraPadding = cameraPadding; QList> grid; QString renderError; @@ -3512,6 +3531,10 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) root["sheetHeight"] = sheet.height(); root["elevation"] = elevation; root["startAzimuth"] = startAzimuth; + if (cameraDistance > 0.0f) + root["cameraDistance"] = cameraDistance; + else + root["cameraPadding"] = cameraPadding; root["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); if (!animationName.isEmpty()) root["animation"] = animationName; diff --git a/src/CLIPipeline_cmdisometric_coverage_test.cpp b/src/CLIPipeline_cmdisometric_coverage_test.cpp index ffcb6fcf6..62001c114 100644 --- a/src/CLIPipeline_cmdisometric_coverage_test.cpp +++ b/src/CLIPipeline_cmdisometric_coverage_test.cpp @@ -84,8 +84,15 @@ QByteArray firstAnimNameForFile(const QString &filePath) if (!Manager::getSingletonPtr()) return QByteArray(); + auto *mgr = Manager::getSingleton(); + auto nodes = mgr->getSceneNodes(); + for (auto *node : nodes) { + mgr->destroyAllAttachedMovableObjects(node); + mgr->destroySceneNode(node); + } + MeshImporterExporter::importer({filePath}); - auto &entities = Manager::getSingleton()->getEntities(); + auto &entities = mgr->getEntities(); QByteArray name; if (!entities.isEmpty() && entities.first()->hasSkeleton()) { Ogre::SkeletonPtr skel = entities.first()->getMesh()->getSkeleton(); @@ -95,10 +102,10 @@ QByteArray firstAnimNameForFile(const QString &filePath) .toUtf8(); } - auto nodes = Manager::getSingleton()->getSceneNodes(); + nodes = mgr->getSceneNodes(); for (auto *node : nodes) { - Manager::getSingleton()->destroyAllAttachedMovableObjects(node); - Manager::getSingleton()->destroySceneNode(node); + mgr->destroyAllAttachedMovableObjects(node); + mgr->destroySceneNode(node); } return name; } @@ -171,6 +178,17 @@ TEST_F(CLIPipelineCmdIsometricCoverageTest, ElevationAndStartAzimuthVariants) EXPECT_EQ(img.height(), 72); } +TEST_F(CLIPipelineCmdIsometricCoverageTest, CameraPaddingJsonReport) +{ + const QString mesh = meshInput("iso_pad.obj"); + const QString out = outPath("iso_pad.png"); + + ArgvBuilder args({"qtmesh", "isometric", mesh, "-o", out, "--directions", "2", "--size", "28", + "--padding", "1.5", "--json"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + ASSERT_TRUE(QFile::exists(out)); +} + TEST_F(CLIPipelineCmdIsometricCoverageTest, AnimatedGridWhenAssetAvailable) { const QString fbx = modelsDir() + "/Twist Dance.fbx"; diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index c95416b46..fe670647f 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -1171,6 +1171,33 @@ TEST(CLIPipelineCmdIsometricError, InvalidResolutionReturnsUsageError) EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); } +TEST(CLIPipelineCmdIsometricError, InvalidCameraDistanceReturnsUsageError) +{ + TestArgv args({"qtmesh", "isometric", "model.fbx", "-o", "out.png", "--camera-distance", "0"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); +} + +TEST_F(CLIPipelineCmdTest, CmdIsometric_CameraDistanceAndPadding) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QByteArray meshArg = writeMinimalObj(tmp.path(), "iso_cam.obj").toUtf8(); + const QByteArray outArg = tmp.filePath("iso_cam.png").toUtf8(); + + TestArgv args({"qtmesh", "isometric", meshArg.constData(), + "-o", outArg.constData(), + "--directions", "2", + "--size", "24", + "--camera-distance", "5", + "--padding", "2"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + + QImage img(QString::fromUtf8(outArg)); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 24); + EXPECT_EQ(img.height(), 48); +} + TEST(CLIPipelineCmdInfoError, NonexistentFile) { TestArgv args({"qtmesh", "info", "/tmp/nonexistent_cli_test_file_12345.fbx"}); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 2b7d39369..1e0b16a9c 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4579,6 +4579,20 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) if (args.contains("elevation")) options.elevationDegrees = static_cast(args.value("elevation").toDouble(30.0)); if (args.contains("directions")) options.directionCount = args.value("directions").toInt(8); if (args.contains("start_azimuth")) options.startAzimuthDegrees = static_cast(args.value("start_azimuth").toDouble(0.0)); + if (args.contains("camera_distance")) { + const double dist = args.value("camera_distance").toDouble(0.0); + if (dist <= 0.0) + return makeErrorResult("Error: camera_distance must be a positive number"); + options.cameraDistance = static_cast(dist); + } + if (args.contains("camera_padding") || args.contains("padding")) { + const double pad = args.contains("camera_padding") + ? args.value("camera_padding").toDouble(1.25) + : args.value("padding").toDouble(1.25); + if (pad <= 0.0) + return makeErrorResult("Error: camera_padding must be a positive number"); + options.cameraPadding = static_cast(pad); + } SentryReporter::addBreadcrumb("file.import", QString("Isometric import %1").arg(QFileInfo(filePath).fileName())); @@ -4642,6 +4656,10 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) result["sheetHeight"] = sheet.height(); result["elevation"] = options.elevationDegrees; result["startAzimuth"] = options.startAzimuthDegrees; + if (options.cameraDistance > 0.0f) + result["cameraDistance"] = options.cameraDistance; + else + result["cameraPadding"] = options.cameraPadding; result["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); if (!animationName.isEmpty()) result["animation"] = animationName; @@ -6571,6 +6589,12 @@ QJsonArray MCPServer::buildToolsList() props["start_azimuth"] = QJsonObject{ {"type", "number"}, {"description", "Rotate row 0 to align with your game's facing direction (degrees, default 0)."}}; + props["camera_distance"] = QJsonObject{ + {"type", "number"}, + {"description", "Fixed orbit distance in world units. Omit or 0 for auto-fit from bounds."}}; + props["camera_padding"] = QJsonObject{ + {"type", "number"}, + {"description", "Multiplier on auto-fit distance when camera_distance is unset (default 1.25)."}}; QJsonArray required; required.append("file"); required.append("output"); diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp index e0c5b902b..299d0d242 100644 --- a/src/ModelIsometricRenderer.cpp +++ b/src/ModelIsometricRenderer.cpp @@ -292,7 +292,7 @@ Ogre::Real fitOrbitDistance(const Ogre::AxisAlignedBox &bounds, const Ogre::Vect } void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, TurntableAxis axis, - float elevationRadians, float paddingFactor) + float elevationRadians, float paddingFactor, float fixedDistance) { IsometricState &st = state(); if (!st.camera || !st.cameraNode || !st.pivotNode || bounds.isNull() || bounds.isInfinite()) @@ -311,7 +311,9 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T const Ogre::Quaternion orbitRot(Ogre::Radian(angleRadians), orbitAxisVector(axis)); Ogre::Vector3 viewDir = orbitRot * localViewDir; - const Ogre::Real distance = fitOrbitDistance(bounds, pivotPoint, viewDir, st.camera, paddingFactor); + const Ogre::Real distance = + fixedDistance > 0.0f ? fixedDistance + : fitOrbitDistance(bounds, pivotPoint, viewDir, st.camera, paddingFactor); const float horiz = distance * horizUnit; const float axial = distance * axialUnit; @@ -518,7 +520,8 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, try { for (int dir = 0; dir < directions; ++dir) { const float azimuth = startAzimuthRad - static_cast(dir) * directionStep; - placeCameraOnAxis(bounds, azimuth, options.upAxis, elevationRad, 1.25f); + placeCameraOnAxis(bounds, azimuth, options.upAxis, elevationRad, options.cameraPadding, + options.cameraDistance); QList row; row.reserve(frames); diff --git a/src/ModelIsometricRenderer.h b/src/ModelIsometricRenderer.h index 95b330917..1713719cd 100644 --- a/src/ModelIsometricRenderer.h +++ b/src/ModelIsometricRenderer.h @@ -26,6 +26,10 @@ struct IsometricOptions { int directionCount = 8; /// Align row 0 to the game's facing direction (degrees). float startAzimuthDegrees = 0.0f; + /// Fixed orbit distance in world units. 0 = auto-fit from bounds (default). + float cameraDistance = 0.0f; + /// Multiplier on auto-fit distance when cameraDistance is 0 (default 1.25). + float cameraPadding = 1.25f; }; class ModelIsometricRenderer diff --git a/website/src/DocsApp.jsx b/website/src/DocsApp.jsx index 3018ba586..a35a06c87 100644 --- a/website/src/DocsApp.jsx +++ b/website/src/DocsApp.jsx @@ -488,6 +488,8 @@ qtmesh turntable -o frame_%02d.png [--frames N] [--axis y|x|z]`} ['--animation NAME', 'Sample this skeletal animation across --frames columns'], ['--elevation DEG', 'Camera elevation above the orbit plane (default 30; alias --camera-height)'], ['--start-azimuth DEG', 'Rotate row 0 to match your game facing (default 0)'], + ['--camera-distance N', 'Fixed orbit distance in world units (default: auto-fit from bounds)'], + ['--padding F', 'Multiplier on auto-fit distance when camera distance is unset (default 1.25)'], ['--resolution N', 'Square per-cell resolution in pixels (default 512; range 16–8192)'], ['--size WxH', 'Per-cell resolution (default 512×512)'], ['--width W / --height H', 'Per-cell dimensions (override --resolution)'], @@ -497,6 +499,7 @@ qtmesh turntable -o frame_%02d.png [--frames N] [--axis y|x|z]`} 'qtmesh isometric character.fbx -o iso.png', 'qtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_walk.png --resolution 256', 'qtmesh isometric prop.glb -o iso_prop.png --directions 4 --resolution 128 --json', + 'qtmesh isometric character.fbx -o iso_zoom.png --padding 0.9 --camera-distance 3', ]} /> diff --git a/website/src/data/content.js b/website/src/data/content.js index 1887f1e8d..14ac9d38f 100644 --- a/website/src/data/content.js +++ b/website/src/data/content.js @@ -108,7 +108,7 @@ export const pipelineExamples = { 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`, - isometric: `qtmesh isometric character.fbx -o iso.png --resolution 256\nqtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_anim.png --resolution 256`, + isometric: `qtmesh isometric character.fbx -o iso.png --resolution 256\nqtmesh isometric character.fbx --animation "Walk" --frames 8 -o iso_anim.png --resolution 256\nqtmesh isometric character.fbx -o iso.png --padding 1.5 --camera-distance 5`, vat: `# Bake a skeletal animation to a Vertex Animation Texture (OpenVAT format).\n# Emits a 16-bit PNG, a JSON sidecar, a vertex-order-aligned glTF,\n# and an Ogre bind sidecar so engine importers (Godot/Unity/Unreal)\n# can realign UV2 to the bake's column order on import.\nqtmesh vat character.fbx --anim "Run" --fps 30 -o bakes/run/\n\n# Drop the engine glue code next to the bake — accepts a comma-separated\n# subset of {godot, unity, unreal} or "all".\nqtmesh vat character.fbx --anim "Dance" --include-shaders godot,unity`, 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 }}`, From 52e19b7a541dd8795328d61683c62a7fc36584fd Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 19 Jun 2026 17:02:32 -0400 Subject: [PATCH 4/6] Address isometric export review feedback. Cap atlas size before rendering, restore recentered transforms after capture, pick animated entities by clip name, validate positive frame counts, use file.export breadcrumbs, and sync README version text. Co-authored-by: Cursor --- CLAUDE.md | 2 +- README.md | 2 +- scripts/sync-doc-versions-from-cmake.sh | 3 + src/CLIPipeline.cpp | 43 ++++++--- ...CLIPipeline_cmdisometric_coverage_test.cpp | 6 +- src/CLIPipeline_test.cpp | 9 +- src/MCPServer.cpp | 7 +- src/ModelIsometricRenderer.cpp | 91 ++++++++++++++++--- src/ModelIsometricRenderer_test.cpp | 24 +++++ 9 files changed, 158 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e275fcc91..dddd99156 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,7 +249,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%. - **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering. - **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`. -- **Isometric sprite export** (`src/ModelIsometricRenderer.h/cpp`, epic #724): headless RTT renderer for 8-direction (configurable) isometric sprite atlases. Reuses the turntable's offscreen capture pattern (RTSS materials, stable orbit framing from rest bounds, single camera re-placed per direction). Outer loop = compass directions (row 0 = front/+Z, clockwise from above); inner loop = evenly spaced animation frames via `AnimationState::setTimePosition` + `_updateAnimation` before readback. Grid layout: rows = directions, columns = frames. Surfaced via `qtmesh isometric`, MCP `generate_isometric_sprites`. Sentry breadcrumb categories `cli.isometric` / `ai.tool_call`. +- **Isometric sprite export** (`src/ModelIsometricRenderer.h/cpp`, epic #724): headless RTT renderer for 8-direction (configurable) isometric sprite atlases. Reuses the turntable's offscreen capture pattern (RTSS materials, stable orbit framing from rest bounds, single camera re-placed per direction). Outer loop = compass directions (row 0 = front/+Z, clockwise from above); inner loop = evenly spaced animation frames via `AnimationState::setTimePosition` + `_updateAnimation` before readback. Grid layout: rows = directions, columns = frames. Options include `--resolution`, `--camera-distance`, and `--padding` (auto-fit multiplier). Surfaced via `qtmesh isometric`, MCP `generate_isometric_sprites`. Sentry breadcrumb categories `file.export` / `ai.tool_call`. - **FBX LOD export gotcha**: `FBXExporter` prefers the cached `qtme.faces.` n-gon binding (set up by quad-migration #326) over `SubMesh::indexData`. The CLI `lod` per-LOD export path in `CLIPipeline::cmdLod` temporarily erases those bindings (and restores them after) so the swapped-in LOD indices actually reach the wire. If you add another LOD-export entry point, mirror that erase/restore pair. ## Development Guidelines diff --git a/README.md b/README.md index c26bac28e..f750baac3 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act **Versioning** - **Always follow the latest GitHub release** — use the Marketplace floating tag `fernandotonon/QtMeshEditor@v1` (same pattern as the [Marketplace example](https://github.com/marketplace/actions/qtmesheditor)). The composite action defaults to `image-tag: latest`, so the Docker CLI tracks the newest published `ghcr.io/fernandotonon/qtmesh` image. -- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.5.3**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. +- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.7.0**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. Pinned workflow template (action + `ghcr.io` image aligned): diff --git a/scripts/sync-doc-versions-from-cmake.sh b/scripts/sync-doc-versions-from-cmake.sh index dafe8fe57..9ad2d0477 100755 --- a/scripts/sync-doc-versions-from-cmake.sh +++ b/scripts/sync-doc-versions-from-cmake.sh @@ -84,6 +84,9 @@ apply_perl_replace() { QTMESH_DOC_VERSION="${VERSION}" perl -i -pe \ 'BEGIN { $v = $ENV{QTMESH_DOC_VERSION}; } s/(?hasSkeleton()) { + if (!entity || !entity->hasSkeleton()) + continue; + Ogre::AnimationStateSet *states = entity->getAllAnimationStates(); + if (states && states->hasAnimationState(animationName.toStdString())) { animatedEntity = entity; break; } } if (!animatedEntity) { - err() << "Error: --animation requires a skinned mesh with a skeleton." << Qt::endl; + err() << "Error: --animation requires a skinned mesh with clip '" << animationName << "'." << Qt::endl; + err() << "Available animations:" << Qt::endl; + for (Ogre::Entity *entity : entityList) { + if (!entity || !entity->hasSkeleton()) + continue; + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + if (!skel) + continue; + const QString entityLabel = QString::fromStdString(entity->getName()); + for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) { + err() << " [" << entityLabel << "] " + << QString::fromStdString(skel->getAnimation(ai)->getName()) << Qt::endl; + } + } return 1; } } @@ -3493,11 +3509,16 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) err() << "Error: " << renderError << Qt::endl; if (renderError.contains(QStringLiteral("not found"))) { err() << "Available animations:" << Qt::endl; - if (animatedEntity) { - Ogre::SkeletonPtr skel = animatedEntity->getMesh()->getSkeleton(); - if (skel) { - for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) - err() << " " << QString::fromStdString(skel->getAnimation(ai)->getName()) << Qt::endl; + for (Ogre::Entity *entity : entityList) { + if (!entity || !entity->hasSkeleton()) + continue; + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + if (!skel) + continue; + const QString entityLabel = QString::fromStdString(entity->getName()); + for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) { + err() << " [" << entityLabel << "] " + << QString::fromStdString(skel->getAnimation(ai)->getName()) << Qt::endl; } } } diff --git a/src/CLIPipeline_cmdisometric_coverage_test.cpp b/src/CLIPipeline_cmdisometric_coverage_test.cpp index 62001c114..b71e56173 100644 --- a/src/CLIPipeline_cmdisometric_coverage_test.cpp +++ b/src/CLIPipeline_cmdisometric_coverage_test.cpp @@ -187,6 +187,10 @@ TEST_F(CLIPipelineCmdIsometricCoverageTest, CameraPaddingJsonReport) "--padding", "1.5", "--json"}); EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); ASSERT_TRUE(QFile::exists(out)); + QImage img(out); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 28); + EXPECT_EQ(img.height(), 56); } TEST_F(CLIPipelineCmdIsometricCoverageTest, AnimatedGridWhenAssetAvailable) @@ -202,7 +206,7 @@ TEST_F(CLIPipelineCmdIsometricCoverageTest, AnimatedGridWhenAssetAvailable) const QString out = outPath("iso_anim.png"); ArgvBuilder args({"qtmesh", "isometric", fbx, "-o", out, "--animation", animName.constData(), "--frames", "4", "--directions", "4", "--size", "32"}); - ASSERT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 0); ASSERT_TRUE(QFile::exists(out)); QImage img(out); diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index fe670647f..e5fa6d89c 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -769,7 +769,8 @@ class CLIPipelineCmdTest : public ::testing::Test { // One-time warmup: the first FBX import in a process sometimes fails // due to lazy initialization in the resource/plugin pipeline. static void SetUpTestSuite() { - if (!tryInitOgre() || !canLoadMeshFiles()) return; + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + ASSERT_TRUE(canLoadMeshFiles()) << "Mesh resources unavailable in test environment"; createStandardOgreMaterials(); QString warmupFile = testDataDir() + "/Twist Dance.fbx"; @@ -1177,6 +1178,12 @@ TEST(CLIPipelineCmdIsometricError, InvalidCameraDistanceReturnsUsageError) EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); } +TEST(CLIPipelineCmdIsometricError, InvalidFramesReturnsUsageError) +{ + TestArgv args({"qtmesh", "isometric", "model.fbx", "-o", "out.png", "--frames", "0"}); + EXPECT_EQ(CLIPipeline::cmdIsometric(args.argc(), args.argv()), 2); +} + TEST_F(CLIPipelineCmdTest, CmdIsometric_CameraDistanceAndPadding) { QTemporaryDir tmp; diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 1e0b16a9c..1ecd64898 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4613,13 +4613,16 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) Ogre::Entity *animatedEntity = nullptr; if (!animationName.isEmpty()) { for (Ogre::Entity *entity : entityList) { - if (entity && entity->hasSkeleton()) { + if (!entity || !entity->hasSkeleton()) + continue; + Ogre::AnimationStateSet *states = entity->getAllAnimationStates(); + if (states && states->hasAnimationState(animationName.toStdString())) { animatedEntity = entity; break; } } if (!animatedEntity) - return makeErrorResult("--animation requires a skinned mesh with a skeleton"); + return makeErrorResult(QString("Error: no skinned entity has animation '%1'").arg(animationName)); } QList> grid; diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp index 299d0d242..ad052965b 100644 --- a/src/ModelIsometricRenderer.cpp +++ b/src/ModelIsometricRenderer.cpp @@ -16,10 +16,17 @@ #include #include +#include #include namespace { +constexpr int kMaxIsometricDirections = 64; +constexpr int kMaxIsometricFrames = 360; +constexpr int kMaxIsometricCellSize = 8192; +constexpr int kMaxIsometricCells = 4096; +constexpr int kMaxIsometricSheetDim = 16384; + Ogre::SceneManager *sceneMgr() { return Manager::getSingletonPtr() ? Manager::getSingleton()->getSceneMgr() : nullptr; @@ -180,8 +187,12 @@ Ogre::AxisAlignedBox combinedWorldBounds(const QList &entities) return box; } -void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisAlignedBox &bounds) +void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisAlignedBox &bounds, + Ogre::Vector3 *outOffset = nullptr) { + if (outOffset) + *outOffset = Ogre::Vector3::ZERO; + if (bounds.isNull() || bounds.isInfinite()) return; @@ -189,6 +200,9 @@ void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisA if (center.squaredLength() < 1e-10f) return; + if (outOffset) + *outOffset = center; + std::unordered_set shifted; for (Ogre::Entity *entity : entities) { if (!entity) @@ -203,6 +217,38 @@ void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisA refreshEntityBounds(entities); } +void restoreEntitiesFromRecenter(const QList &entities, const Ogre::Vector3 &offset) +{ + if (offset.squaredLength() < 1e-10f) + return; + + std::unordered_set shifted; + for (Ogre::Entity *entity : entities) { + if (!entity) + continue; + Ogre::SceneNode *node = entity->getParentSceneNode(); + if (!node || !shifted.insert(node).second) + continue; + node->translate(offset, Ogre::Node::TS_WORLD); + } + refreshEntityBounds(entities); +} + +struct RecenterGuard { + const QList &entities; + Ogre::Vector3 offset; + bool active = false; + + RecenterGuard(const QList &ents, Ogre::Vector3 off) : entities(ents), offset(off) + { + active = offset.squaredLength() >= 1e-10f; + } + ~RecenterGuard() { + if (active) + restoreEntitiesFromRecenter(entities, offset); + } +}; + Ogre::Vector3 orbitAxisVector(TurntableAxis axis) { switch (axis) { @@ -454,10 +500,27 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, return false; } - const int width = std::max(16, options.width); - const int height = std::max(16, options.height); - const int directions = std::clamp(options.directionCount, 1, 64); - const int frames = std::clamp(frameCount, 1, 360); + const int width = std::clamp(options.width, 16, kMaxIsometricCellSize); + const int height = std::clamp(options.height, 16, kMaxIsometricCellSize); + const int directions = std::clamp(options.directionCount, 1, kMaxIsometricDirections); + const int frames = std::clamp(frameCount, 1, kMaxIsometricFrames); + + const std::int64_t sheetW = static_cast(frames) * width; + const std::int64_t sheetH = static_cast(directions) * height; + if (static_cast(directions) * frames > kMaxIsometricCells + || sheetW > kMaxIsometricSheetDim || sheetH > kMaxIsometricSheetDim) { + if (errorOut) { + *errorOut = + QStringLiteral("Grid too large (%1 directions × %2 frames at %3×%4 px; max %5 cells, %6 px/side)") + .arg(directions) + .arg(frames) + .arg(width) + .arg(height) + .arg(kMaxIsometricCells) + .arg(kMaxIsometricSheetDim); + } + return false; + } const bool wantsAnimation = animatedEntity && !animationName.isEmpty(); Ogre::AnimationState *animState = nullptr; @@ -489,8 +552,10 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, return false; } - recenterEntitiesAtOrigin(entities, bounds); + Ogre::Vector3 recenterOffset = Ogre::Vector3::ZERO; + recenterEntitiesAtOrigin(entities, bounds, &recenterOffset); bounds = combinedWorldBounds(entities); + RecenterGuard recenterGuard(entities, recenterOffset); const float elevationRad = Ogre::Degree(std::clamp(options.elevationDegrees, -80.0f, 80.0f)).valueRadians(); @@ -510,8 +575,8 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, } SentryReporter::addBreadcrumb( - "cli.isometric", - QStringLiteral("render start dirs=%1 frames=%2 animated=%3") + "file.export", + QStringLiteral("isometric render start dirs=%1 frames=%2 animated=%3") .arg(directions) .arg(frames) .arg(wantsAnimation ? animationName : QStringLiteral("static"))); @@ -541,22 +606,24 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, animState->setEnabled(false); restoreIsometricLighting(sm); - SentryReporter::addBreadcrumb("cli.isometric", - QStringLiteral("render ok dirs=%1 frames=%2").arg(directions).arg(frames)); + SentryReporter::addBreadcrumb("file.export", + QStringLiteral("isometric render ok dirs=%1 frames=%2") + .arg(directions) + .arg(frames)); return true; } catch (const Ogre::Exception &e) { outRowsByDirection->clear(); restoreIsometricLighting(sm); if (errorOut) *errorOut = QString::fromStdString(e.getFullDescription()); - SentryReporter::addBreadcrumb("cli.isometric", QStringLiteral("render failed: Ogre exception")); + SentryReporter::addBreadcrumb("file.export", QStringLiteral("isometric render failed: Ogre exception")); return false; } catch (...) { outRowsByDirection->clear(); restoreIsometricLighting(sm); if (errorOut) *errorOut = QStringLiteral("Isometric render failed"); - SentryReporter::addBreadcrumb("cli.isometric", QStringLiteral("render failed")); + SentryReporter::addBreadcrumb("file.export", QStringLiteral("isometric render failed")); return false; } } diff --git a/src/ModelIsometricRenderer_test.cpp b/src/ModelIsometricRenderer_test.cpp index c5896fa64..b6c1e1ec6 100644 --- a/src/ModelIsometricRenderer_test.cpp +++ b/src/ModelIsometricRenderer_test.cpp @@ -13,6 +13,7 @@ class ModelIsometricRendererTest : public ::testing::Test { void SetUp() override { ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + ASSERT_TRUE(canLoadMeshFiles()) << "Mesh resources unavailable in test environment"; ModelIsometricRenderer::shutdown(); } @@ -66,6 +67,29 @@ TEST_F(ModelIsometricRendererTest, ClampsMinimumSizeAndDirectionCount) EXPECT_EQ(grid.first().first().height(), 16); } +TEST_F(ModelIsometricRendererTest, RejectsOversizedGrid) +{ + PrimitiveObject::createCube(QStringLiteral("IsoOversizeCube")); + + QList entities; + for (auto *obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == "Entity") + entities.append(static_cast(obj)); + } + ASSERT_FALSE(entities.isEmpty()); + + IsometricOptions options; + options.width = 512; + options.height = 512; + options.directionCount = 64; + + QList> grid; + QString err; + EXPECT_FALSE(ModelIsometricRenderer::renderToGrid(entities, nullptr, {}, 360, options, &grid, &err)); + EXPECT_TRUE(err.contains(QStringLiteral("Grid too large"))); + EXPECT_TRUE(grid.isEmpty()); +} + TEST_F(ModelIsometricRendererTest, StaticGridDimensions) { PrimitiveObject::createSphere(QStringLiteral("IsoTestSphere")); From d25e2cf14f106b587cd20ae4712742595633443a Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 19 Jun 2026 18:47:36 -0400 Subject: [PATCH 5/6] Address SonarCloud findings and remaining isometric review items. Extract isometric CLI parsing and grid capture helpers, share animation entity lookup, fix RecenterGuard rule compliance, and repair the doc-sync Perl version replacement. Co-authored-by: Cursor --- scripts/sync-doc-versions-from-cmake.sh | 2 +- src/CLIPipeline.cpp | 406 ++++++++++++------------ src/MCPServer.cpp | 21 +- src/ModelIsometricRenderer.cpp | 135 +++++--- src/ModelIsometricRenderer.h | 7 + 5 files changed, 310 insertions(+), 261 deletions(-) diff --git a/scripts/sync-doc-versions-from-cmake.sh b/scripts/sync-doc-versions-from-cmake.sh index 9ad2d0477..9f1629e78 100755 --- a/scripts/sync-doc-versions-from-cmake.sh +++ b/scripts/sync-doc-versions-from-cmake.sh @@ -85,7 +85,7 @@ apply_perl_replace() { 'BEGIN { $v = $ENV{QTMESH_DOC_VERSION}; } s/(?jsonOutput = true; + continue; + } + if (arg == "-o" && i + 1 < argc) { + out->outputPath = QString(argv[++i]); + continue; + } + if (arg == "--animation" && i + 1 < argc) { + out->animationName = QString(argv[++i]); + continue; + } + if (arg == "--frames" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &out->frameCount) || out->frameCount <= 0) { + err() << "Error: --frames must be a positive integer." << Qt::endl; + return 2; + } + out->frameCountExplicit = true; + continue; + } + if (arg == "--directions" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &out->directionCount) || out->directionCount <= 0) { + err() << "Error: --directions must be a positive integer." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--resolution" && i + 1 < argc) { + int res = 0; + if (!parseCliInt(QString(argv[++i]), &res) || res < 16 || res > 8192) { + err() << "Error: --resolution must be an integer in [16..8192]." << Qt::endl; + return 2; + } + out->width = res; + out->height = res; + continue; + } + if (arg == "--width" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &out->width)) { + err() << "Error: Invalid value for --width." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--height" && i + 1 < argc) { + if (!parseCliInt(QString(argv[++i]), &out->height)) { + err() << "Error: Invalid value for --height." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--size" && i + 1 < argc) { + const auto sizeArg = QString(argv[++i]); + if (const int xPos = static_cast(sizeArg.indexOf(QLatin1Char('x'))); xPos > 0) { + if (!parseCliInt(sizeArg.left(xPos), &out->width) + || !parseCliInt(sizeArg.mid(xPos + 1), &out->height)) { + err() << "Error: Invalid value for --size (expected WxH)." << Qt::endl; + return 2; + } + } else if (!parseCliInt(sizeArg, &out->width)) { + err() << "Error: Invalid value for --size." << Qt::endl; + return 2; + } else { + out->height = out->width; + } + continue; + } + if (arg == "--elevation" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &out->elevation)) { + err() << "Error: Invalid value for --elevation." << Qt::endl; + return 2; + } + continue; + } + if ((arg == "--camera-height" || arg == "--camera_height") && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &out->elevation)) { + err() << "Error: Invalid value for --camera-height." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--start-azimuth" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &out->startAzimuth)) { + err() << "Error: Invalid value for --start-azimuth." << Qt::endl; + return 2; + } + continue; + } + if ((arg == "--camera-distance" || arg == "--camera_distance") && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &out->cameraDistance) || out->cameraDistance <= 0.0f) { + err() << "Error: --camera-distance must be a positive number." << Qt::endl; + return 2; + } + continue; + } + if (arg == "--padding" && i + 1 < argc) { + if (!parseCliFloat(QString(argv[++i]), &out->cameraPadding) || out->cameraPadding <= 0.0f) { + err() << "Error: --padding must be a positive number." << Qt::endl; + return 2; + } + continue; + } + if (!arg.startsWith(QLatin1Char('-')) && out->inputPath.isEmpty()) { + out->inputPath = arg; + continue; + } + } + + if (out->inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh isometric -o [--directions N] [--frames N]" << Qt::endl; + return 2; + } + if (out->outputPath.isEmpty()) { + err() << "Error: Output path required (-o)." << Qt::endl; + err() << "Usage: qtmesh isometric -o [--directions 8]" << Qt::endl; + return 2; + } + + if (!out->animationName.isEmpty() && !out->frameCountExplicit) + out->frameCount = 8; + + return 0; +} + } // namespace bool CLIPipeline::initOgreHeadless() @@ -3291,149 +3442,13 @@ int CLIPipeline::cmdTurntable(int argc, char* argv[]) int CLIPipeline::cmdIsometric(int argc, char* argv[]) { - // isometric -o [--directions N] [--frames N] [--animation NAME] - // [--size WxH] [--resolution N] [--width W] [--height H] [--elevation deg] - // [--start-azimuth deg] [--camera-distance N] [--padding F] [--json] - QString inputPath, outputPath, animationName; - int frameCount = 1; - bool frameCountExplicit = false; - int directionCount = 8; - int width = 512; - int height = 512; - float elevation = 30.0f; - float startAzimuth = 0.0f; - float cameraDistance = 0.0f; - float cameraPadding = 1.25f; - bool jsonOutput = false; + IsometricCliParams params; + if (const int parseRc = parseIsometricCliArgs(argc, argv, ¶ms); parseRc != 0) + return parseRc; - for (int i = 1; i < argc; ++i) { - QString arg(argv[i]); - if (arg == "isometric" || arg == "--cli") - continue; - if (arg == "--json") { - jsonOutput = true; - continue; - } - if (arg == "-o" && i + 1 < argc) { - outputPath = QString(argv[++i]); - continue; - } - if (arg == "--animation" && i + 1 < argc) { - animationName = QString(argv[++i]); - continue; - } - if (arg == "--frames" && i + 1 < argc) { - if (!parseCliInt(QString(argv[++i]), &frameCount) || frameCount <= 0) { - err() << "Error: --frames must be a positive integer." << Qt::endl; - return 2; - } - frameCountExplicit = true; - continue; - } - if (arg == "--directions" && i + 1 < argc) { - if (!parseCliInt(QString(argv[++i]), &directionCount) || directionCount <= 0) { - err() << "Error: --directions must be a positive integer." << Qt::endl; - return 2; - } - continue; - } - if (arg == "--resolution" && i + 1 < argc) { - int res = 0; - if (!parseCliInt(QString(argv[++i]), &res) || res < 16 || res > 8192) { - err() << "Error: --resolution must be an integer in [16..8192]." << Qt::endl; - return 2; - } - width = height = res; - continue; - } - if (arg == "--width" && i + 1 < argc) { - if (!parseCliInt(QString(argv[++i]), &width)) { - err() << "Error: Invalid value for --width." << Qt::endl; - return 2; - } - continue; - } - if (arg == "--height" && i + 1 < argc) { - if (!parseCliInt(QString(argv[++i]), &height)) { - err() << "Error: Invalid value for --height." << Qt::endl; - return 2; - } - continue; - } - if (arg == "--size" && i + 1 < argc) { - const QString sizeArg = QString(argv[++i]); - const int xPos = sizeArg.indexOf(QLatin1Char('x')); - if (xPos > 0) { - if (!parseCliInt(sizeArg.left(xPos), &width) || !parseCliInt(sizeArg.mid(xPos + 1), &height)) { - err() << "Error: Invalid value for --size (expected WxH)." << Qt::endl; - return 2; - } - } else if (!parseCliInt(sizeArg, &width)) { - err() << "Error: Invalid value for --size." << Qt::endl; - return 2; - } else { - height = width; - } - continue; - } - if (arg == "--elevation" && i + 1 < argc) { - if (!parseCliFloat(QString(argv[++i]), &elevation)) { - err() << "Error: Invalid value for --elevation." << Qt::endl; - return 2; - } - continue; - } - if ((arg == "--camera-height" || arg == "--camera_height") && i + 1 < argc) { - if (!parseCliFloat(QString(argv[++i]), &elevation)) { - err() << "Error: Invalid value for --camera-height." << Qt::endl; - return 2; - } - continue; - } - if (arg == "--start-azimuth" && i + 1 < argc) { - if (!parseCliFloat(QString(argv[++i]), &startAzimuth)) { - err() << "Error: Invalid value for --start-azimuth." << Qt::endl; - return 2; - } - continue; - } - if ((arg == "--camera-distance" || arg == "--camera_distance") && i + 1 < argc) { - if (!parseCliFloat(QString(argv[++i]), &cameraDistance) || cameraDistance <= 0.0f) { - err() << "Error: --camera-distance must be a positive number." << Qt::endl; - return 2; - } - continue; - } - if (arg == "--padding" && i + 1 < argc) { - if (!parseCliFloat(QString(argv[++i]), &cameraPadding) || cameraPadding <= 0.0f) { - err() << "Error: --padding must be a positive number." << Qt::endl; - return 2; - } - continue; - } - if (!arg.startsWith(QLatin1Char('-')) && inputPath.isEmpty()) { - inputPath = arg; - continue; - } - } - - if (inputPath.isEmpty()) { - err() << "Error: No input file specified." << Qt::endl; - err() << "Usage: qtmesh isometric -o [--directions N] [--frames N]" << Qt::endl; - return 2; - } - if (outputPath.isEmpty()) { - err() << "Error: Output path required (-o)." << Qt::endl; - err() << "Usage: qtmesh isometric -o [--directions 8]" << Qt::endl; - return 2; - } - - if (!animationName.isEmpty() && !frameCountExplicit) - frameCount = 8; - - QFileInfo fi(inputPath); + const QFileInfo fi(params.inputPath); if (!fi.exists()) { - err() << "Error: File not found: " << inputPath << Qt::endl; + err() << "Error: File not found: " << params.inputPath << Qt::endl; return 1; } @@ -3443,9 +3458,10 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) SentryReporter::addBreadcrumb("ui.action", QString("Isometric .%1 dirs=%2 frames=%3 anim=%4") .arg(fi.suffix()) - .arg(directionCount) - .arg(frameCount) - .arg(animationName.isEmpty() ? QStringLiteral("static") : animationName)); + .arg(params.directionCount) + .arg(params.frameCount) + .arg(params.animationName.isEmpty() ? QStringLiteral("static") + : params.animationName)); SentryReporter::addBreadcrumb("file.import", fi.absoluteFilePath()); MeshImporterExporter::importer({fi.absoluteFilePath()}); @@ -3458,113 +3474,85 @@ int CLIPipeline::cmdIsometric(int argc, char* argv[]) if (entityList.isEmpty()) { SentryReporter::captureMessage(QString("CLI isometric: import failed (.%1)").arg(fi.suffix()), "error"); - err() << "Error: Failed to load file: " << inputPath << Qt::endl; + err() << "Error: Failed to load file: " << params.inputPath << Qt::endl; return 1; } Ogre::Entity *animatedEntity = nullptr; - if (!animationName.isEmpty()) { - for (Ogre::Entity *entity : entityList) { - if (!entity || !entity->hasSkeleton()) - continue; - Ogre::AnimationStateSet *states = entity->getAllAnimationStates(); - if (states && states->hasAnimationState(animationName.toStdString())) { - animatedEntity = entity; - break; - } - } + if (!params.animationName.isEmpty()) { + animatedEntity = + ModelIsometricRenderer::findEntityWithAnimation(entityList, params.animationName); if (!animatedEntity) { - err() << "Error: --animation requires a skinned mesh with clip '" << animationName << "'." << Qt::endl; + err() << "Error: --animation requires a skinned mesh with clip '" << params.animationName + << "'." << Qt::endl; err() << "Available animations:" << Qt::endl; - for (Ogre::Entity *entity : entityList) { - if (!entity || !entity->hasSkeleton()) - continue; - Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); - if (!skel) - continue; - const QString entityLabel = QString::fromStdString(entity->getName()); - for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) { - err() << " [" << entityLabel << "] " - << QString::fromStdString(skel->getAnimation(ai)->getName()) << Qt::endl; - } - } + err() << ModelIsometricRenderer::formatAvailableAnimations(entityList); return 1; } } IsometricOptions options; - options.width = width; - options.height = height; - options.elevationDegrees = elevation; - options.directionCount = qBound(1, directionCount, 64); - options.startAzimuthDegrees = startAzimuth; - options.cameraDistance = cameraDistance; - options.cameraPadding = cameraPadding; + options.width = params.width; + options.height = params.height; + options.elevationDegrees = params.elevation; + options.directionCount = qBound(1, params.directionCount, 64); + options.startAzimuthDegrees = params.startAzimuth; + options.cameraDistance = params.cameraDistance; + options.cameraPadding = params.cameraPadding; QList> grid; - QString renderError; - if (!ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, animationName, frameCount, options, - &grid, &renderError)) { + if (QString renderError; + !ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, params.animationName, + params.frameCount, options, &grid, &renderError)) { ModelIsometricRenderer::shutdown(); err() << "Error: " << renderError << Qt::endl; if (renderError.contains(QStringLiteral("not found"))) { err() << "Available animations:" << Qt::endl; - for (Ogre::Entity *entity : entityList) { - if (!entity || !entity->hasSkeleton()) - continue; - Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); - if (!skel) - continue; - const QString entityLabel = QString::fromStdString(entity->getName()); - for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) { - err() << " [" << entityLabel << "] " - << QString::fromStdString(skel->getAnimation(ai)->getName()) << Qt::endl; - } - } + err() << ModelIsometricRenderer::formatAvailableAnimations(entityList); } return 1; } const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); - if (sheet.isNull() || !sheet.save(outputPath)) { + if (sheet.isNull() || !sheet.save(params.outputPath)) { ModelIsometricRenderer::shutdown(); - err() << "Error: Failed to write isometric sprite sheet " << outputPath << Qt::endl; + err() << "Error: Failed to write isometric sprite sheet " << params.outputPath << Qt::endl; return 1; } ModelIsometricRenderer::shutdown(); - SentryReporter::addBreadcrumb("file.export", QFileInfo(outputPath).absoluteFilePath()); + SentryReporter::addBreadcrumb("file.export", QFileInfo(params.outputPath).absoluteFilePath()); - const int dirs = grid.size(); - const int frames = dirs > 0 ? grid.first().size() : 0; + const int dirs = static_cast(grid.size()); + const int frames = dirs > 0 ? static_cast(grid.first().size()) : 0; - if (jsonOutput) { + if (params.jsonOutput) { QJsonObject root; root["input"] = fi.absoluteFilePath(); - root["output"] = QFileInfo(outputPath).absoluteFilePath(); + root["output"] = QFileInfo(params.outputPath).absoluteFilePath(); root["directions"] = dirs; root["frames"] = frames; - root["cellWidth"] = width; - root["cellHeight"] = height; - if (width == height) - root["resolution"] = width; + root["cellWidth"] = params.width; + root["cellHeight"] = params.height; + if (params.width == params.height) + root["resolution"] = params.width; root["sheetWidth"] = sheet.width(); root["sheetHeight"] = sheet.height(); - root["elevation"] = elevation; - root["startAzimuth"] = startAzimuth; - if (cameraDistance > 0.0f) - root["cameraDistance"] = cameraDistance; + root["elevation"] = params.elevation; + root["startAzimuth"] = params.startAzimuth; + if (params.cameraDistance > 0.0f) + root["cameraDistance"] = params.cameraDistance; else - root["cameraPadding"] = cameraPadding; + root["cameraPadding"] = params.cameraPadding; root["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); - if (!animationName.isEmpty()) - root["animation"] = animationName; + if (!params.animationName.isEmpty()) + root["animation"] = params.animationName; cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)) + "\n"); } else { cliWrite(QString("Wrote isometric sprite sheet (%1 directions × %2 frames): %3\n") .arg(dirs) .arg(frames) - .arg(QFileInfo(outputPath).fileName())); + .arg(QFileInfo(params.outputPath).fileName())); } return 0; diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 1ecd64898..4b5b65062 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4572,7 +4572,8 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) const int res = args.value("resolution").toInt(512); if (res < 16 || res > 8192) return makeErrorResult("Error: resolution must be an integer in [16..8192]"); - options.width = options.height = res; + options.width = res; + options.height = res; } if (args.contains("width")) options.width = args.value("width").toInt(options.width); if (args.contains("height")) options.height = args.value("height").toInt(options.height); @@ -4612,22 +4613,14 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) Ogre::Entity *animatedEntity = nullptr; if (!animationName.isEmpty()) { - for (Ogre::Entity *entity : entityList) { - if (!entity || !entity->hasSkeleton()) - continue; - Ogre::AnimationStateSet *states = entity->getAllAnimationStates(); - if (states && states->hasAnimationState(animationName.toStdString())) { - animatedEntity = entity; - break; - } - } + animatedEntity = ModelIsometricRenderer::findEntityWithAnimation(entityList, animationName); if (!animatedEntity) return makeErrorResult(QString("Error: no skinned entity has animation '%1'").arg(animationName)); } QList> grid; - QString renderError; - if (!ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, animationName, frameCount, options, + if (QString renderError; + !ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, animationName, frameCount, options, &grid, &renderError)) { ModelIsometricRenderer::shutdown(); return makeErrorResult(QString("Isometric render failed: %1").arg(renderError)); @@ -4640,8 +4633,8 @@ QJsonObject MCPServer::toolGenerateIsometricSprites(const QJsonObject &args) SentryReporter::addBreadcrumb("file.export", QFileInfo(outputPath).absoluteFilePath()); - const int dirs = grid.size(); - const int frames = dirs > 0 ? grid.first().size() : 0; + const int dirs = static_cast(grid.size()); + const int frames = dirs > 0 ? static_cast(grid.first().size()) : 0; QJsonObject result = makeSuccessResult( QString("Wrote isometric sprite sheet (%1 directions × %2 frames): %3") diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp index ad052965b..f99131c15 100644 --- a/src/ModelIsometricRenderer.cpp +++ b/src/ModelIsometricRenderer.cpp @@ -8,6 +8,7 @@ #include "SentryReporter.h" #include +#include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include namespace { @@ -56,7 +58,7 @@ void prepareSceneForCapture(const QList &entities) { SelectionSet::getSingleton()->clear(); - for (Ogre::Entity *entity : entities) { + for (const Ogre::Entity *entity : entities) { if (!entity) continue; if (Ogre::SceneNode *node = entity->getParentSceneNode()) @@ -167,7 +169,7 @@ bool ensureRenderTarget(int width, int height, const Ogre::ColourValue &bg, QStr void refreshEntityBounds(const QList &entities) { - for (Ogre::Entity *entity : entities) { + for (const Ogre::Entity *entity : entities) { if (!entity) continue; if (Ogre::SceneNode *node = entity->getParentSceneNode()) @@ -179,7 +181,7 @@ Ogre::AxisAlignedBox combinedWorldBounds(const QList &entities) { Ogre::AxisAlignedBox box; box.setNull(); - for (Ogre::Entity *entity : entities) { + for (const Ogre::Entity *entity : entities) { if (!entity) continue; box.merge(entity->getWorldBoundingBox(true)); @@ -204,7 +206,7 @@ void recenterEntitiesAtOrigin(const QList &entities, Ogre::AxisA *outOffset = center; std::unordered_set shifted; - for (Ogre::Entity *entity : entities) { + for (const Ogre::Entity *entity : entities) { if (!entity) continue; Ogre::SceneNode *node = entity->getParentSceneNode(); @@ -223,7 +225,7 @@ void restoreEntitiesFromRecenter(const QList &entities, const Og return; std::unordered_set shifted; - for (Ogre::Entity *entity : entities) { + for (const Ogre::Entity *entity : entities) { if (!entity) continue; Ogre::SceneNode *node = entity->getParentSceneNode(); @@ -243,9 +245,16 @@ struct RecenterGuard { { active = offset.squaredLength() >= 1e-10f; } - ~RecenterGuard() { - if (active) + RecenterGuard(const RecenterGuard &) = delete; + RecenterGuard &operator=(const RecenterGuard &) = delete; + ~RecenterGuard() noexcept + { + if (!active) + return; + try { restoreEntitiesFromRecenter(entities, offset); + } catch (...) { + } } }; @@ -301,7 +310,7 @@ void cameraAxesFromViewDir(const Ogre::Vector3 &viewDir, const Ogre::Vector3 &wo } Ogre::Real fitOrbitDistance(const Ogre::AxisAlignedBox &bounds, const Ogre::Vector3 &pivotPoint, - const Ogre::Vector3 &viewDir, Ogre::Camera *camera, float paddingFactor) + const Ogre::Vector3 &viewDir, const Ogre::Camera *camera, float paddingFactor) { const Ogre::Vector3 center = pivotPoint; Ogre::Vector3 dir = viewDir; @@ -372,7 +381,7 @@ void placeCameraOnAxis(const Ogre::AxisAlignedBox &bounds, float angleRadians, T void prepareMaterialsForCapture(const QList &entities) { std::unordered_set processed; - for (Ogre::Entity *entity : entities) { + for (const Ogre::Entity *entity : entities) { if (!entity) continue; MeshImporterExporter::applyNormalMapsToEntity(entity); @@ -421,6 +430,33 @@ void applyAnimationFrame(Ogre::Entity *entity, Ogre::AnimationState *animState, entity->_updateAnimation(); } +bool captureIsometricGrid(const Ogre::AxisAlignedBox &bounds, const IsometricOptions &options, int width, + int height, int directions, int frames, float elevationRad, float startAzimuthRad, + float directionStep, bool wantsAnimation, Ogre::Entity *animatedEntity, + Ogre::AnimationState *animState, float animLength, QList> *outRowsByDirection) +{ + outRowsByDirection->reserve(directions); + for (int dir = 0; dir < directions; ++dir) { + const float azimuth = startAzimuthRad - static_cast(dir) * directionStep; + placeCameraOnAxis(bounds, azimuth, options.upAxis, elevationRad, options.cameraPadding, + options.cameraDistance); + + QList row; + row.reserve(frames); + for (int frame = 0; frame < frames; ++frame) { + if (wantsAnimation) { + const float t = (frames == 1) ? 0.0f + : animLength * static_cast(frame) / static_cast(frames - 1); + applyAnimationFrame(animatedEntity, animState, t); + } + state().renderTarget->update(); + row.append(readRenderTarget(width, height)); + } + outRowsByDirection->append(row); + } + return true; +} + } // namespace QString ModelIsometricRenderer::directionOrderConvention() @@ -506,8 +542,8 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, const int frames = std::clamp(frameCount, 1, kMaxIsometricFrames); const std::int64_t sheetW = static_cast(frames) * width; - const std::int64_t sheetH = static_cast(directions) * height; - if (static_cast(directions) * frames > kMaxIsometricCells + if (const std::int64_t sheetH = static_cast(directions) * height; + static_cast(directions) * frames > kMaxIsometricCells || sheetW > kMaxIsometricSheetDim || sheetH > kMaxIsometricSheetDim) { if (errorOut) { *errorOut = @@ -531,13 +567,13 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, *errorOut = QStringLiteral("Animated entity has no skeleton"); return false; } - Ogre::AnimationStateSet *states = animatedEntity->getAllAnimationStates(); + const Ogre::AnimationStateSet *states = animatedEntity->getAllAnimationStates(); if (!states || !states->hasAnimationState(animationName.toStdString())) { if (errorOut) *errorOut = QStringLiteral("Animation '%1' not found").arg(animationName); return false; } - animState = states->getAnimationState(animationName.toStdString()); + animState = animatedEntity->getAllAnimationStates()->getAnimationState(animationName.toStdString()); animLength = animState->getLength(); } @@ -567,10 +603,10 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, applyIsometricLighting(sm); if (wantsAnimation) { - for (const auto &[key, as] : animatedEntity->getAllAnimationStates()->getAnimationStates()) { - Q_UNUSED(key); - if (as) - as->setEnabled(false); + const Ogre::AnimationStateSet *states = animatedEntity->getAllAnimationStates(); + for (const auto &entry : states->getAnimationStates()) { + if (entry.second) + entry.second->setEnabled(false); } } @@ -583,24 +619,9 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, outRowsByDirection->reserve(directions); try { - for (int dir = 0; dir < directions; ++dir) { - const float azimuth = startAzimuthRad - static_cast(dir) * directionStep; - placeCameraOnAxis(bounds, azimuth, options.upAxis, elevationRad, options.cameraPadding, - options.cameraDistance); - - QList row; - row.reserve(frames); - for (int frame = 0; frame < frames; ++frame) { - if (wantsAnimation) { - const float t = (frames == 1) ? 0.0f - : animLength * static_cast(frame) / static_cast(frames - 1); - applyAnimationFrame(animatedEntity, animState, t); - } - state().renderTarget->update(); - row.append(readRenderTarget(width, height)); - } - outRowsByDirection->append(row); - } + captureIsometricGrid(bounds, options, width, height, directions, frames, elevationRad, startAzimuthRad, + directionStep, wantsAnimation, animatedEntity, animState, animLength, + outRowsByDirection); if (wantsAnimation && animState) animState->setEnabled(false); @@ -618,6 +639,13 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, *errorOut = QString::fromStdString(e.getFullDescription()); SentryReporter::addBreadcrumb("file.export", QStringLiteral("isometric render failed: Ogre exception")); return false; + } catch (const std::exception &e) { + outRowsByDirection->clear(); + restoreIsometricLighting(sm); + if (errorOut) + *errorOut = QString::fromUtf8(e.what()); + SentryReporter::addBreadcrumb("file.export", QStringLiteral("isometric render failed: std exception")); + return false; } catch (...) { outRowsByDirection->clear(); restoreIsometricLighting(sm); @@ -633,7 +661,7 @@ QImage ModelIsometricRenderer::composeDirectionGrid(const QList> & if (rowsByDirection.isEmpty()) return {}; - const int directionCount = rowsByDirection.size(); + const int directionCount = static_cast(rowsByDirection.size()); int frameCount = 0; int frameW = 0; int frameH = 0; @@ -655,7 +683,7 @@ QImage ModelIsometricRenderer::composeDirectionGrid(const QList> & QPainter painter(&sheet); for (int dir = 0; dir < directionCount; ++dir) { const QList &row = rowsByDirection.at(dir); - for (int frame = 0; frame < row.size(); ++frame) { + for (int frame = 0; frame < static_cast(row.size()); ++frame) { const QImage &src = row.at(frame); if (src.width() != frameW || src.height() != frameH) continue; @@ -664,3 +692,36 @@ QImage ModelIsometricRenderer::composeDirectionGrid(const QList> & } return sheet; } + +Ogre::Entity *ModelIsometricRenderer::findEntityWithAnimation(const QList &entities, + const QString &animationName) +{ + const std::string anim = animationName.toStdString(); + for (Ogre::Entity *entity : entities) { + if (!entity || !entity->hasSkeleton()) + continue; + const Ogre::AnimationStateSet *states = entity->getAllAnimationStates(); + if (states && states->hasAnimationState(anim)) + return entity; + } + return nullptr; +} + +QString ModelIsometricRenderer::formatAvailableAnimations(const QList &entities) +{ + QString text; + QTextStream stream(&text); + for (const Ogre::Entity *entity : entities) { + if (!entity || !entity->hasSkeleton()) + continue; + const Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + if (!skel) + continue; + const QString entityLabel = QString::fromStdString(entity->getName()); + for (unsigned short ai = 0; ai < skel->getNumAnimations(); ++ai) { + stream << " [" << entityLabel << "] " + << QString::fromStdString(skel->getAnimation(ai)->getName()) << "\n"; + } + } + return text; +} diff --git a/src/ModelIsometricRenderer.h b/src/ModelIsometricRenderer.h index 1713719cd..7924b31c1 100644 --- a/src/ModelIsometricRenderer.h +++ b/src/ModelIsometricRenderer.h @@ -48,6 +48,13 @@ class ModelIsometricRenderer /// Destroy isometric camera / RTT resources (safe to call repeatedly). static void shutdown(); + + /// First skinned entity that owns `animationName`, or nullptr. + static Ogre::Entity *findEntityWithAnimation(const QList &entities, + const QString &animationName); + + /// Multi-line listing of `[entity] clip` rows for CLI/MCP error hints. + static QString formatAvailableAnimations(const QList &entities); }; #endif // MODELISOMETRICRENDERER_H From fbc3da9a4a6d2751607ac790837e040b57034571 Mon Sep 17 00:00:00 2001 From: Fernando Date: Fri, 19 Jun 2026 19:25:53 -0400 Subject: [PATCH 6/6] Fix Sonar null-dereference in isometric capture loop. Guard render-target update when RTT is unavailable and propagate failure from captureIsometricGrid. Co-authored-by: Cursor --- src/ModelIsometricRenderer.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp index f99131c15..beb89a2c0 100644 --- a/src/ModelIsometricRenderer.cpp +++ b/src/ModelIsometricRenderer.cpp @@ -254,6 +254,7 @@ struct RecenterGuard { try { restoreEntitiesFromRecenter(entities, offset); } catch (...) { + // Best-effort restore; swallow to keep destructor noexcept. } } }; @@ -449,7 +450,10 @@ bool captureIsometricGrid(const Ogre::AxisAlignedBox &bounds, const IsometricOpt : animLength * static_cast(frame) / static_cast(frames - 1); applyAnimationFrame(animatedEntity, animState, t); } - state().renderTarget->update(); + Ogre::RenderTarget *renderTarget = state().renderTarget; + if (!renderTarget) + return false; + renderTarget->update(); row.append(readRenderTarget(width, height)); } outRowsByDirection->append(row); @@ -619,9 +623,15 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, outRowsByDirection->reserve(directions); try { - captureIsometricGrid(bounds, options, width, height, directions, frames, elevationRad, startAzimuthRad, - directionStep, wantsAnimation, animatedEntity, animState, animLength, - outRowsByDirection); + if (!captureIsometricGrid(bounds, options, width, height, directions, frames, elevationRad, startAzimuthRad, + directionStep, wantsAnimation, animatedEntity, animState, animLength, + outRowsByDirection)) { + outRowsByDirection->clear(); + restoreIsometricLighting(sm); + if (errorOut) + *errorOut = QStringLiteral("Isometric render target is not available"); + return false; + } if (wantsAnimation && animState) animState->setEnabled(false);