From 8af52c5d0a12dd2af42eea32e8fc3bf2accce9f3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 20 Jun 2026 17:27:31 -0400 Subject: [PATCH 1/3] Add in-app isometric sprite export UI and MCP test coverage (#724). Surfaces the existing isometric renderer in Animation Mode with a dialog, hides editor grid chrome during capture, and adds controller/MCP regression tests. Co-authored-by: Cursor --- CLAUDE.md | 3 +- qml/IsometricSpritesDialog.qml | 342 ++++++++++++++++++ qml/PropertiesPanel.qml | 97 +++++ src/CMakeLists.txt | 2 + src/IsometricSpritesController.cpp | 253 +++++++++++++ src/IsometricSpritesController.h | 74 ++++ src/IsometricSpritesController_test.cpp | 65 ++++ ...GenerateIsometricSprites_coverage_test.cpp | 202 +++++++++++ src/ModelIsometricRenderer.cpp | 63 ++++ src/ModelIsometricRenderer_test.cpp | 14 + src/mainwindow.cpp | 32 ++ src/qml_resources.qrc | 1 + 12 files changed, 1147 insertions(+), 1 deletion(-) create mode 100644 qml/IsometricSpritesDialog.qml create mode 100644 src/IsometricSpritesController.cpp create mode 100644 src/IsometricSpritesController.h create mode 100644 src/IsometricSpritesController_test.cpp create mode 100644 src/MCPServerGenerateIsometricSprites_coverage_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index dddd99156..9fa8eaaf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ 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 --elevation 35 # camera angle in degrees (default 30) 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) @@ -249,7 +250,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. 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`. +- **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 `--elevation` / `--camera-height`, `--resolution`, `--camera-distance`, and `--padding` (auto-fit multiplier). Editor grid and non-export scene entities are hidden during capture. Surfaced via `qtmesh isometric`, MCP `generate_isometric_sprites`, and **Animation Mode → Mode Tools → "Export Isometric Sprites…"** (`qml/IsometricSpritesDialog.qml`, `IsometricSpritesController`). 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/qml/IsometricSpritesDialog.qml b/qml/IsometricSpritesDialog.qml new file mode 100644 index 000000000..b2a521ccb --- /dev/null +++ b/qml/IsometricSpritesDialog.qml @@ -0,0 +1,342 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import MaterialEditorQML 1.0 +import PropertiesPanel 1.0 + +// Epic #724: in-app isometric / 8-direction sprite atlas export. +Window { + id: dialog + title: "Isometric Sprites" + width: 560 + height: 580 + minimumWidth: 480 + minimumHeight: 520 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property string outputPath: "" + property string animationName: "" + property int directions: 8 + property int frames: 8 + property int resolution: 256 + property double elevation: 30 + property double padding: 1.25 + property double cameraDistance: 0 + property double startAzimuth: 0 + + property string lastStatus: "" + property bool lastWasError: false + + readonly property bool useAnimation: dialog.animationName.length > 0 + readonly property int labelColWidth: 100 + + function open() { + dialog.lastStatus = "" + dialog.lastWasError = false + dialog.show() + dialog.raise() + dialog.requestActivate() + keyCapture.forceActiveFocus() + } + + function runExport() { + if (IsometricSpritesController.isExporting) return + if (!IsometricSpritesController.hasExportableSelection) return + if (dialog.outputPath.length === 0) { + dialog.lastStatus = "Choose an output PNG path first." + dialog.lastWasError = true + return + } + const r = IsometricSpritesController.exportSelected( + dialog.outputPath, + dialog.animationName, + dialog.directions, + dialog.frames, + dialog.resolution, + dialog.elevation, + dialog.padding, + dialog.cameraDistance, + dialog.startAzimuth) + if (r && r.ok) { + dialog.lastStatus = "✓ " + r.outputPath + + " (" + r.sheetWidth + "×" + r.sheetHeight + " px)" + dialog.lastWasError = false + } else { + dialog.lastStatus = "✗ " + (r && r.error ? r.error : "export failed") + dialog.lastWasError = true + } + } + + Item { + id: keyCapture + anchors.fill: parent + focus: true + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + dialog.close() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + dialog.runExport() + event.accepted = true + } + } + } + + Connections { + target: IsometricSpritesController + function onOutputPathPicked(path) { + dialog.show() + dialog.raise() + dialog.requestActivate() + if (path.length > 0) + dialog.outputPath = path + } + } + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + implicitWidth: btnLabel.implicitWidth + 16 + Layout.preferredWidth: Math.max(90, implicitWidth) + height: 26 + radius: 3 + color: btnMa.containsMouse && buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + opacity: buttonEnabled ? 1.0 : 0.45 + Text { + id: btnLabel + anchors.centerIn: parent + text: btn.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: btnMa + anchors.fill: parent + hoverEnabled: true + enabled: btn.buttonEnabled + cursorShape: btn.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: btn.clicked() + } + } + + component InspectorLabel: Text { + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + component InspectorNumberField: Rectangle { + id: nf + property double value: 0 + property double minValue: 0 + property double maxValue: 1e9 + property bool isInt: false + signal newValue(double v) + implicitWidth: 80 + height: 24 + color: PropertiesPanelController.inputColor + border.color: ni.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + TextInput { + id: ni + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + text: nf.isInt ? Math.round(nf.value).toString() : nf.value.toFixed(2) + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + enabled: !IsometricSpritesController.isExporting + onEditingFinished: { + const n = nf.isInt ? parseInt(text, 10) : parseFloat(text) + if (isNaN(n)) { + text = nf.isInt ? Math.round(nf.value).toString() : nf.value.toFixed(2) + return + } + nf.newValue(Math.max(nf.minValue, Math.min(nf.maxValue, n))) + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 10 + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.85 + text: "Export the selected mesh as an isometric sprite atlas: rows are compass " + + "directions, columns are animation frames. Row 0 is the front view (+Z)." + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + InspectorLabel { text: "Output:"; Layout.preferredWidth: dialog.labelColWidth } + Rectangle { + Layout.fillWidth: true + height: 24 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + Text { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + text: dialog.outputPath.length > 0 + ? dialog.outputPath + : "(click Browse… to choose)" + color: PropertiesPanelController.textColor + opacity: dialog.outputPath.length > 0 ? 1.0 : 0.45 + font.pixelSize: 11 + elide: Text.ElideMiddle + verticalAlignment: Text.AlignVCenter + } + } + InspectorButton { + label: "Browse…" + Layout.preferredWidth: 90 + buttonEnabled: !IsometricSpritesController.isExporting + onClicked: { + dialog.hide() + IsometricSpritesController.requestOutputPathPick(dialog.outputPath) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + InspectorLabel { text: "Animation:"; Layout.preferredWidth: dialog.labelColWidth } + ThemedComboBox { + Layout.fillWidth: true + height: 24 + font.pixelSize: 11 + model: ["(static mesh)"].concat(IsometricSpritesController.availableAnimations) + currentIndex: dialog.animationName.length === 0 + ? 0 + : Math.max(0, model.indexOf(dialog.animationName)) + enabled: !IsometricSpritesController.isExporting + onCurrentTextChanged: { + dialog.animationName = (currentText === "(static mesh)") ? "" : currentText + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + InspectorLabel { text: "Directions:"; Layout.preferredWidth: dialog.labelColWidth } + InspectorNumberField { + isInt: true + value: dialog.directions + minValue: 1 + maxValue: 64 + onNewValue: function(v) { dialog.directions = Math.round(v) } + } + InspectorLabel { text: "Frames:"; Layout.preferredWidth: 56 } + InspectorNumberField { + isInt: true + value: dialog.useAnimation ? dialog.frames : 1 + minValue: 1 + maxValue: 360 + opacity: dialog.useAnimation ? 1.0 : 0.45 + enabled: dialog.useAnimation + onNewValue: function(v) { dialog.frames = Math.round(v) } + } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + InspectorLabel { text: "Cell px:"; Layout.preferredWidth: dialog.labelColWidth } + InspectorNumberField { + isInt: true + value: dialog.resolution + minValue: 16 + maxValue: 8192 + onNewValue: function(v) { dialog.resolution = Math.round(v) } + } + InspectorLabel { text: "Elevation°:"; Layout.preferredWidth: 56 } + InspectorNumberField { + value: dialog.elevation + minValue: -80 + maxValue: 80 + onNewValue: function(v) { dialog.elevation = v } + } + Item { Layout.fillWidth: true } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + InspectorLabel { text: "Padding:"; Layout.preferredWidth: dialog.labelColWidth } + InspectorNumberField { + value: dialog.padding + minValue: 0.1 + maxValue: 10 + onNewValue: function(v) { dialog.padding = v } + } + InspectorLabel { text: "Cam dist:"; Layout.preferredWidth: 56 } + InspectorNumberField { + value: dialog.cameraDistance + minValue: 0 + maxValue: 1e6 + onNewValue: function(v) { dialog.cameraDistance = v } + } + Item { Layout.fillWidth: true } + } + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.65 + font.pixelSize: 10 + text: "Padding scales auto-fit framing. Camera distance 0 = auto-fit × padding." + } + + Item { Layout.fillHeight: true } + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + visible: dialog.lastStatus.length > 0 + color: dialog.lastWasError ? "#cc6666" : "#66aa66" + text: dialog.lastStatus + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Item { Layout.fillWidth: true } + InspectorButton { + label: "Close" + Layout.preferredWidth: 90 + onClicked: dialog.close() + } + InspectorButton { + label: IsometricSpritesController.isExporting ? "Exporting…" : "Export PNG" + Layout.preferredWidth: 110 + buttonEnabled: IsometricSpritesController.hasExportableSelection + && !IsometricSpritesController.isExporting + onClicked: dialog.runExport() + } + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 730f2e140..1b94886c1 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -302,6 +302,17 @@ Rectangle { Component.onCompleted: content = animationModeToolsComponent } + // ---- Isometric sprites (#724) ---- + CollapsibleSection { + title: "Isometric Sprites" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.AnimationMode, + IsometricSpritesController.hasExportableSelection) + expanded: false + + Component.onCompleted: content = isometricSpritesToolsComponent + } + // ---- Skinning (Animation mode) ---- // Issue #402: auto skin weights. Surfaced in Animation // Mode because skinning governs how the mesh deforms @@ -1122,6 +1133,70 @@ Rectangle { } } + // ---- Isometric Sprites Tools (Animation mode) ---- + Component { + id: isometricSpritesToolsComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + opacity: 0.8 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: "Render the selected mesh to an 8-direction isometric sprite " + + "atlas (rows = directions, columns = animation frames)." + } + + Rectangle { + id: isoBtn + width: Math.min(parent.width - 16, isoLabel.implicitWidth + 16) + height: 26 + radius: 3 + opacity: IsometricSpritesController.hasExportableSelection ? 1.0 : 0.45 + color: isoMa.containsMouse && IsometricSpritesController.hasExportableSelection + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + activeFocusOnTab: IsometricSpritesController.hasExportableSelection + Accessible.role: Accessible.Button + Accessible.name: "Export Isometric Sprites" + Keys.onSpacePressed: if (IsometricSpritesController.hasExportableSelection) root.openIsometricSpritesDialog() + Keys.onReturnPressed: if (IsometricSpritesController.hasExportableSelection) root.openIsometricSpritesDialog() + Keys.onEnterPressed: if (IsometricSpritesController.hasExportableSelection) root.openIsometricSpritesDialog() + border.color: isoBtn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: isoBtn.activeFocus ? 2 : 1 + + Text { + id: isoLabel + anchors.centerIn: parent + text: "Export Isometric Sprites…" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: isoMa + anchors.fill: parent + hoverEnabled: true + enabled: IsometricSpritesController.hasExportableSelection + cursorShape: IsometricSpritesController.hasExportableSelection + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: root.openIsometricSpritesDialog() + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: IsometricSpritesController.hasExportableSelection + ? "Render an isometric directions×frames PNG atlas from the live scene." + : "Select a mesh first." + } + } + } + } + // ---- Skinning Tools Content (Animation mode) ---- // Issue #402: auto skin weights. The "Compute Skin Weights…" // button opens the dialog; it disables on static meshes @@ -4029,6 +4104,28 @@ Rectangle { } } + Loader { + id: isometricSpritesLoader + active: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/IsometricSpritesDialog.qml" + onLoaded: if (item && item.open) item.open() + onStatusChanged: { + if (status === Loader.Error) + console.warn("IsometricSpritesDialog failed to load") + } + } + function openIsometricSpritesDialog() { + if (!isometricSpritesLoader.active) { + isometricSpritesLoader.active = true + } else if (isometricSpritesLoader.item) { + isometricSpritesLoader.item.open() + } else if (isometricSpritesLoader.status === Loader.Error) { + isometricSpritesLoader.active = false + isometricSpritesLoader.active = true + } + } + // ---- Material Presets Content ---- Component { id: materialPresetsComponent diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3247b83ec..5c9bcc96d 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -102,6 +102,7 @@ VertexColorBaker.cpp VATBaker.cpp VATBakerController.cpp VATShaderEmitter.cpp +IsometricSpritesController.cpp MinimalEXRWriter.cpp MorphAnimationManager.cpp NodeAnimationManager.cpp @@ -268,6 +269,7 @@ AssetScanController.h MaterialPreviewRenderer.h ModelTurntableRenderer.h ModelIsometricRenderer.h +IsometricSpritesController.h EditableMesh.h EditModeController.h EditorModeController.h diff --git a/src/IsometricSpritesController.cpp b/src/IsometricSpritesController.cpp new file mode 100644 index 000000000..a52e82a53 --- /dev/null +++ b/src/IsometricSpritesController.cpp @@ -0,0 +1,253 @@ +#include "IsometricSpritesController.h" + +#include "Manager.h" +#include "ModelIsometricRenderer.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include +#include +#include +#include +#include + +IsometricSpritesController *IsometricSpritesController::s_instance = nullptr; + +IsometricSpritesController *IsometricSpritesController::instance() +{ + if (!s_instance) + s_instance = new IsometricSpritesController(); + return s_instance; +} + +IsometricSpritesController *IsometricSpritesController::qmlInstance(QQmlEngine *, QJSEngine *) +{ + return instance(); +} + +void IsometricSpritesController::kill() +{ + delete s_instance; + s_instance = nullptr; +} + +IsometricSpritesController::IsometricSpritesController(QObject *parent) : QObject(parent) +{ + if (auto *sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, this, [this]() { + refreshAnimations(); + emit selectionChanged(); + }); + } + refreshAnimations(); +} + +bool IsometricSpritesController::hasExportableSelection() const +{ + auto *sel = SelectionSet::getSingleton(); + if (!sel) + return false; + const auto entities = sel->getResolvedEntities(); + for (Ogre::Entity *entity : entities) { + if (entity && entity->getMesh()) + return true; + } + return false; +} + +bool IsometricSpritesController::hasSkinnedSelection() const +{ + auto *sel = SelectionSet::getSingleton(); + if (!sel) + return false; + const auto entities = sel->getResolvedEntities(); + for (Ogre::Entity *entity : entities) { + if (entity && entity->hasSkeleton()) + return true; + } + return false; +} + +void IsometricSpritesController::refreshAnimations() +{ + QStringList fresh; + auto *sel = SelectionSet::getSingleton(); + if (sel) { + const auto entities = sel->getResolvedEntities(); + for (Ogre::Entity *entity : entities) { + if (!entity || !entity->hasSkeleton()) + continue; + if (auto *states = entity->getAllAnimationStates()) { + auto it = states->getAnimationStateIterator(); + while (it.hasMoreElements()) { + if (auto *state = it.getNext()) { + const QString name = QString::fromStdString(state->getAnimationName()); + if (!name.isEmpty() && !fresh.contains(name)) + fresh << name; + } + } + } + } + } + fresh.sort(Qt::CaseInsensitive); + if (fresh != m_animations) { + m_animations = std::move(fresh); + emit availableAnimationsChanged(); + } +} + +void IsometricSpritesController::setExporting(bool exporting) +{ + if (m_isExporting == exporting) + return; + m_isExporting = exporting; + emit isExportingChanged(); +} + +void IsometricSpritesController::requestOutputPathPick(const QString &startPath) +{ + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Isometric sprite save dialog requested (seed=%1)").arg(startPath)); + emit outputPathPickRequested(startPath); +} + +QString IsometricSpritesController::chooseOutputPath(const QString &startPath) +{ + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Isometric sprite save dialog opened (seed=%1)").arg(startPath)); + + QString seed = startPath; + if (seed.isEmpty() || !QFileInfo(seed).isDir()) + seed = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + if (seed.isEmpty()) + seed = QDir::homePath(); + seed = QDir(seed).filePath(QStringLiteral("isometric_sprites.png")); + + QApplication::processEvents(); + QWidget *parent = QApplication::activeWindow(); + if (parent) { + parent->raise(); + parent->activateWindow(); + } + QApplication::processEvents(); + + const QString chosen = QFileDialog::getSaveFileName( + nullptr, QStringLiteral("Save isometric sprite sheet"), seed, + QStringLiteral("PNG image (*.png)"), nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + + SentryReporter::addBreadcrumb( + "ui.action", + chosen.isEmpty() ? QStringLiteral("Isometric sprite save dialog cancelled") + : QStringLiteral("Isometric sprite save dialog accepted: %1").arg(chosen)); + return chosen; +} + +QVariantMap IsometricSpritesController::exportSelected(const QString &outputPath, + const QString &animationName, + int directions, + int frames, + int resolution, + double elevation, + double padding, + double cameraDistance, + double startAzimuth) +{ + QVariantMap result; + auto fail = [&](const QString &msg) { + result["ok"] = false; + result["error"] = msg; + emit exportFinished(false, outputPath, msg); + return result; + }; + + SentryReporter::addBreadcrumb("ui.action", QStringLiteral("Isometric sprite export requested")); + + if (m_isExporting) + return fail(QStringLiteral("Export already in progress")); + if (outputPath.isEmpty()) + return fail(QStringLiteral("Output path is required")); + if (!hasExportableSelection()) + return fail(QStringLiteral("Select a mesh to export")); + if (directions <= 0) + return fail(QStringLiteral("directions must be positive")); + if (frames <= 0) + return fail(QStringLiteral("frames must be positive")); + if (resolution < 16 || resolution > 8192) + return fail(QStringLiteral("resolution must be in [16..8192]")); + + auto *sel = SelectionSet::getSingleton(); + if (!sel) + return fail(QStringLiteral("Selection is unavailable")); + + const QList entityList = sel->getResolvedEntities(); + if (entityList.isEmpty()) + return fail(QStringLiteral("No mesh selected")); + + const QString anim = animationName.trimmed(); + const int frameCount = anim.isEmpty() ? 1 : frames; + + Ogre::Entity *animatedEntity = nullptr; + if (!anim.isEmpty()) { + animatedEntity = ModelIsometricRenderer::findEntityWithAnimation(entityList, anim); + if (!animatedEntity) + return fail(QStringLiteral("Animation '%1' not found on selection").arg(anim)); + } + + IsometricOptions options; + options.width = resolution; + options.height = resolution; + options.elevationDegrees = static_cast(elevation); + options.directionCount = directions; + options.startAzimuthDegrees = static_cast(startAzimuth); + options.cameraDistance = static_cast(cameraDistance); + options.cameraPadding = static_cast(padding <= 0.0 ? 1.25 : padding); + + SentryReporter::addBreadcrumb( + "file.export", + QStringLiteral("isometric gui export start dirs=%1 frames=%2 anim=%3") + .arg(directions) + .arg(frameCount) + .arg(anim.isEmpty() ? QStringLiteral("static") : anim)); + + setExporting(true); + + QList> grid; + QString renderError; + const bool rendered = + ModelIsometricRenderer::renderToGrid(entityList, animatedEntity, anim, frameCount, options, &grid, + &renderError); + + ModelIsometricRenderer::shutdown(); + + // renderToGrid clears the selection for a clean capture — restore it. + sel->clear(); + for (Ogre::Entity *entity : entityList) { + if (entity) + sel->append(entity); + } + + setExporting(false); + + if (!rendered) + return fail(renderError.isEmpty() ? QStringLiteral("Isometric render failed") : renderError); + + const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); + if (sheet.isNull() || !sheet.save(outputPath)) + return fail(QStringLiteral("Failed to write %1").arg(outputPath)); + + SentryReporter::addBreadcrumb("file.export", QFileInfo(outputPath).absoluteFilePath()); + + result["ok"] = true; + result["outputPath"] = outputPath; + result["directions"] = directions; + result["frames"] = frameCount; + result["sheetWidth"] = sheet.width(); + result["sheetHeight"] = sheet.height(); + result["directionOrder"] = ModelIsometricRenderer::directionOrderConvention(); + result["error"] = QString(); + emit exportFinished(true, outputPath, QString()); + return result; +} diff --git a/src/IsometricSpritesController.h b/src/IsometricSpritesController.h new file mode 100644 index 000000000..c49ae9b69 --- /dev/null +++ b/src/IsometricSpritesController.h @@ -0,0 +1,74 @@ +#ifndef ISOMETRIC_SPRITES_CONTROLLER_H +#define ISOMETRIC_SPRITES_CONTROLLER_H + +#include +#include +#include +#include +#include + +/// QML-facing singleton for in-app isometric sprite export (#724). +/// Wraps `ModelIsometricRenderer` against the live scene selection. +class IsometricSpritesController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool hasExportableSelection READ hasExportableSelection NOTIFY selectionChanged) + Q_PROPERTY(bool hasSkinnedSelection READ hasSkinnedSelection NOTIFY selectionChanged) + Q_PROPERTY(QStringList availableAnimations READ availableAnimations NOTIFY availableAnimationsChanged) + Q_PROPERTY(bool isExporting READ isExporting NOTIFY isExportingChanged) + +public: + static IsometricSpritesController *instance(); + static IsometricSpritesController *qmlInstance(QQmlEngine *engine, QJSEngine *scriptEngine); + static void kill(); + + bool hasExportableSelection() const; + bool hasSkinnedSelection() const; + QStringList availableAnimations() const { return m_animations; } + bool isExporting() const { return m_isExporting; } + + Q_INVOKABLE void refreshAnimations(); + + /// Opens the save dialog via MainWindow (reliable parent for QQuickWidget-hosted QML windows). + Q_INVOKABLE void requestOutputPathPick(const QString &startPath = QString()); + + /// Direct save dialog (tests / headless). GUI should prefer requestOutputPathPick(). + Q_INVOKABLE QString chooseOutputPath(const QString &startPath = QString()); + + /// Render the current selection to an isometric directions×frames PNG. + /// Empty `animationName` → static mesh (one frame per direction). + /// Returns `{ ok, outputPath, error, directions, frames, sheetWidth, sheetHeight }`. + Q_INVOKABLE QVariantMap exportSelected(const QString &outputPath, + const QString &animationName, + int directions, + int frames, + int resolution, + double elevation, + double padding, + double cameraDistance, + double startAzimuth); + +signals: + void selectionChanged(); + void availableAnimationsChanged(); + void isExportingChanged(); + void exportFinished(bool ok, const QString &outputPath, const QString &error); + void outputPathPickRequested(const QString &startPath); + void outputPathPicked(const QString &path); + +private: + explicit IsometricSpritesController(QObject *parent = nullptr); + ~IsometricSpritesController() override = default; + + void setExporting(bool exporting); + + QStringList m_animations; + bool m_isExporting = false; + + static IsometricSpritesController *s_instance; +}; + +#endif // ISOMETRIC_SPRITES_CONTROLLER_H diff --git a/src/IsometricSpritesController_test.cpp b/src/IsometricSpritesController_test.cpp new file mode 100644 index 000000000..bb22c9b0e --- /dev/null +++ b/src/IsometricSpritesController_test.cpp @@ -0,0 +1,65 @@ +#include + +#include + +#include "IsometricSpritesController.h" +#include "SelectionSet.h" + +namespace { + +void clearSelection() +{ + if (auto *sel = SelectionSet::getSingleton()) + sel->clear(); +} + +} // namespace + +TEST(IsometricSpritesControllerStandalone, InstanceIsSingleton) +{ + auto *a = IsometricSpritesController::instance(); + auto *b = IsometricSpritesController::instance(); + EXPECT_EQ(a, b); + EXPECT_NE(a, nullptr); +} + +TEST(IsometricSpritesControllerStandalone, NoExportableSelectionWhenEmpty) +{ + clearSelection(); + auto *ctrl = IsometricSpritesController::instance(); + ctrl->refreshAnimations(); + EXPECT_FALSE(ctrl->hasExportableSelection()); + EXPECT_FALSE(ctrl->hasSkinnedSelection()); + EXPECT_TRUE(ctrl->availableAnimations().isEmpty()); +} + +TEST(IsometricSpritesControllerStandalone, ExportRefusedWithoutSelection) +{ + clearSelection(); + auto *ctrl = IsometricSpritesController::instance(); + QSignalSpy spy(ctrl, &IsometricSpritesController::exportFinished); + const QVariantMap result = ctrl->exportSelected( + QStringLiteral("/tmp/iso_test.png"), QString(), 8, 8, 64, 30.0, 1.25, 0.0, 0.0); + EXPECT_FALSE(result.value(QStringLiteral("ok")).toBool()); + ASSERT_GE(spy.count(), 1); + EXPECT_FALSE(spy.first().at(0).toBool()); +} + +TEST(IsometricSpritesControllerStandalone, ExportRefusedWithoutOutputPath) +{ + clearSelection(); + auto *ctrl = IsometricSpritesController::instance(); + const QVariantMap result = + ctrl->exportSelected(QString(), QString(), 8, 1, 64, 30.0, 1.25, 0.0, 0.0); + EXPECT_FALSE(result.value(QStringLiteral("ok")).toBool()); + EXPECT_TRUE(result.value(QStringLiteral("error")).toString().contains(QStringLiteral("Output"))); +} + +TEST(IsometricSpritesControllerStandalone, RequestOutputPathPickEmitsSignal) +{ + auto *ctrl = IsometricSpritesController::instance(); + QSignalSpy pickRequested(ctrl, &IsometricSpritesController::outputPathPickRequested); + ctrl->requestOutputPathPick(QStringLiteral("/tmp/seed.png")); + EXPECT_EQ(pickRequested.count(), 1); + EXPECT_EQ(pickRequested.first().at(0).toString(), QStringLiteral("/tmp/seed.png")); +} diff --git a/src/MCPServerGenerateIsometricSprites_coverage_test.cpp b/src/MCPServerGenerateIsometricSprites_coverage_test.cpp new file mode 100644 index 000000000..2f6dfe2af --- /dev/null +++ b/src/MCPServerGenerateIsometricSprites_coverage_test.cpp @@ -0,0 +1,202 @@ +// Coverage tests for MCPServer::toolGenerateIsometricSprites (#724). +// +// Exercises validation branches and the end-to-end file-in / PNG-out path via +// callTool("generate_isometric_sprites", …) — same renderer as `qtmesh isometric`. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "MCPServer.h" +#include "Manager.h" +#include "TestHelpers.h" + +#include + +namespace { + +QString isoResultText(const QJsonObject &result) +{ + const QJsonArray content = result["content"].toArray(); + if (content.isEmpty()) + return QString(); + return content[0].toObject()["text"].toString(); +} + +bool isoIsError(const QJsonObject &result) +{ + return result["isError"].toBool(false); +} + +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; +} + +bool imageHasNonBackgroundPixels(const QImage &img) +{ + if (img.isNull() || img.width() < 2 || img.height() < 2) + return false; + const QRgb ref = img.pixel(0, 0); + for (int y = 0; y < img.height(); ++y) { + for (int x = 0; x < img.width(); ++x) { + const QRgb px = img.pixel(x, y); + if (qAbs(qRed(px) - qRed(ref)) > 8 || qAbs(qGreen(px) - qGreen(ref)) > 8 + || qAbs(qBlue(px) - qBlue(ref)) > 8 || qAbs(qAlpha(px) - qAlpha(ref)) > 8) + return true; + } + } + return false; +} + +class MCPServerGenerateIsometricSpritesCoverageTest : public ::testing::Test +{ +protected: + void SetUp() override + { + server.reset(); + Manager::kill(); + QThread::msleep(50); + + app = qobject_cast(QCoreApplication::instance()); + ASSERT_NE(app, nullptr); + + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + createStandardOgreMaterials(); + + server = std::make_unique(); + ASSERT_TRUE(tmp.isValid()); + } + + void TearDown() override + { + server.reset(); + Manager::kill(); + if (app) + app->processEvents(); + } + + QString meshInput(const QString &objName) + { + const QString robot = testRobotMeshPath(); + if (!robot.isEmpty() && QFile::exists(robot)) + return robot; + const QString obj = writeCubeObj(tmp.path(), objName); + EXPECT_FALSE(obj.isEmpty()); + return obj; + } + + std::unique_ptr server; + QApplication *app = nullptr; + QTemporaryDir tmp; +}; + +TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, MissingArgsReturnsError) +{ + const QJsonObject result = server->callTool(QStringLiteral("generate_isometric_sprites"), {}); + EXPECT_TRUE(isoIsError(result)); + EXPECT_TRUE(isoResultText(result).contains(QStringLiteral("missing required"))); +} + +TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, MissingFileReturnsError) +{ + QJsonObject args; + args["output"] = tmp.filePath(QStringLiteral("out.png")); + const QJsonObject result = server->callTool(QStringLiteral("generate_isometric_sprites"), args); + EXPECT_TRUE(isoIsError(result)); + EXPECT_TRUE(isoResultText(result).contains(QStringLiteral("missing required"))); +} + +TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, NonexistentFileReturnsError) +{ + QJsonObject args; + args["file"] = QStringLiteral("/nonexistent/missing_mesh.fbx"); + args["output"] = tmp.filePath(QStringLiteral("out.png")); + const QJsonObject result = server->callTool(QStringLiteral("generate_isometric_sprites"), args); + EXPECT_TRUE(isoIsError(result)); + EXPECT_TRUE(isoResultText(result).contains(QStringLiteral("file not found"), Qt::CaseInsensitive)); +} + +TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, InvalidResolutionReturnsError) +{ + const QString mesh = meshInput(QStringLiteral("iso_bad_res.obj")); + QJsonObject args; + args["file"] = mesh; + args["output"] = tmp.filePath(QStringLiteral("out.png")); + args["resolution"] = 8; + const QJsonObject result = server->callTool(QStringLiteral("generate_isometric_sprites"), args); + EXPECT_TRUE(isoIsError(result)); + EXPECT_TRUE(isoResultText(result).contains(QStringLiteral("resolution"))); +} + +TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, StaticGridWritesPngWithMeshPixels) +{ + const QString mesh = meshInput(QStringLiteral("iso_mcp.obj")); + const QString out = tmp.filePath(QStringLiteral("iso_mcp.png")); + + QJsonObject args; + args["file"] = mesh; + args["output"] = out; + args["directions"] = 2; + args["resolution"] = 32; + args["elevation"] = 30.0; + + const QJsonObject result = server->callTool(QStringLiteral("generate_isometric_sprites"), args); + EXPECT_FALSE(isoIsError(result)) << isoResultText(result).toStdString(); + EXPECT_TRUE(QFile::exists(out)); + + QImage img(out); + ASSERT_FALSE(img.isNull()); + EXPECT_EQ(img.width(), 32); + EXPECT_EQ(img.height(), 64); + EXPECT_TRUE(imageHasNonBackgroundPixels(img)) + << "sprite sheet should contain mesh pixels, not a flat background"; + + EXPECT_EQ(result["directions"].toInt(), 2); + EXPECT_EQ(result["frames"].toInt(), 1); + EXPECT_EQ(result["resolution"].toInt(), 32); + EXPECT_FALSE(result["directionOrder"].toString().isEmpty()); +} + +TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, AppearsInToolList) +{ + const QJsonArray tools = server->buildToolsList(); + bool found = false; + for (const auto &entry : tools) { + if (entry.toObject()["name"].toString() == QStringLiteral("generate_isometric_sprites")) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "generate_isometric_sprites must be exposed in tools/list"; +} + +} // namespace diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp index beb89a2c0..9b39209e1 100644 --- a/src/ModelIsometricRenderer.cpp +++ b/src/ModelIsometricRenderer.cpp @@ -20,6 +20,8 @@ #include #include #include +#include +#include namespace { @@ -66,6 +68,66 @@ void prepareSceneForCapture(const QList &entities) } } +/// Hide editor chrome (grid, non-export entities) during RTT capture. +/// The grid uses query flags that bypass the isometric viewport mask. +class EditorCaptureGuard { +public: + explicit EditorCaptureGuard(const QList &visibleEntities) + { + std::unordered_set keep; + keep.reserve(static_cast(visibleEntities.size())); + for (Ogre::Entity *entity : visibleEntities) { + if (entity) + keep.insert(entity); + } + + if (Manager::getSingletonPtr() && Manager::getSingleton()->hasSceneNode("GridLine_node")) { + m_gridNode = Manager::getSingleton()->getSceneNode("GridLine_node"); + if (m_gridNode) { + m_gridWasVisible = m_gridNode->getAttachedObject(0) + ? m_gridNode->getAttachedObject(0)->getVisible() + : true; + m_gridNode->setVisible(false); + } + } + + if (Manager::getSingletonPtr()) { + for (Ogre::Entity *other : Manager::getSingleton()->getEntities()) { + if (!other || other->getMovableType() != "Entity" || keep.count(other) != 0) + continue; + if (Ogre::SceneNode *node = other->getParentSceneNode()) { + const bool wasVisible = + !node->getAttachedObject(0) || node->getAttachedObject(0)->getVisible(); + if (wasVisible) { + m_hiddenNodes.emplace_back(node, true); + node->setVisible(false); + } + } + } + } + } + + EditorCaptureGuard(const EditorCaptureGuard &) = delete; + EditorCaptureGuard &operator=(const EditorCaptureGuard &) = delete; + + ~EditorCaptureGuard() noexcept + { + try { + if (m_gridNode) + m_gridNode->setVisible(m_gridWasVisible); + for (auto &[node, wasVisible] : m_hiddenNodes) + node->setVisible(wasVisible); + } catch (...) { + // Best-effort restore; swallow to keep destructor noexcept. + } + } + +private: + Ogre::SceneNode *m_gridNode = nullptr; + bool m_gridWasVisible = false; + std::vector> m_hiddenNodes; +}; + void applyIsometricLighting(Ogre::SceneManager *sm) { IsometricState &st = state(); @@ -605,6 +667,7 @@ bool ModelIsometricRenderer::renderToGrid(const QList &entities, prepareSceneForCapture(entities); prepareMaterialsForCapture(entities); applyIsometricLighting(sm); + EditorCaptureGuard editorGuard(entities); if (wantsAnimation) { const Ogre::AnimationStateSet *states = animatedEntity->getAllAnimationStates(); diff --git a/src/ModelIsometricRenderer_test.cpp b/src/ModelIsometricRenderer_test.cpp index b6c1e1ec6..80d547122 100644 --- a/src/ModelIsometricRenderer_test.cpp +++ b/src/ModelIsometricRenderer_test.cpp @@ -119,6 +119,20 @@ TEST_F(ModelIsometricRendererTest, StaticGridDimensions) const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); EXPECT_EQ(sheet.width(), 64); EXPECT_EQ(sheet.height(), 48 * 4); + + bool hasVariation = false; + const QRgb ref = sheet.pixel(0, 0); + for (int y = 0; y < sheet.height() && !hasVariation; ++y) { + for (int x = 0; x < sheet.width(); ++x) { + const QRgb px = sheet.pixel(x, y); + if (qAbs(qRed(px) - qRed(ref)) > 8 || qAbs(qGreen(px) - qGreen(ref)) > 8 + || qAbs(qBlue(px) - qBlue(ref)) > 8) { + hasVariation = true; + break; + } + } + } + EXPECT_TRUE(hasVariation) << "rendered atlas should contain mesh pixels"; } TEST_F(ModelIsometricRendererTest, ComposeDirectionGrid) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index cfed817b2..00a559357 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,5 +1,8 @@ #include +#include #include +#include +#include #include #include #ifndef Q_OS_WIN @@ -105,6 +108,8 @@ #include "TexturePaintController.h" #include "PaintBufferImageProvider.h" #include "VATBakerController.h" +#include "ThemeManager.h" +#include "IsometricSpritesController.h" #include "MorphAnimationManager.h" #include "EditorModeController.h" #include "QtMeshCloudClient.h" @@ -432,6 +437,7 @@ MainWindow::~MainWindow() UvUnwrapController::kill(); QuadRetopoController::kill(); SkinWeightsController::kill(); + IsometricSpritesController::kill(); MeshDepthRenderer::shutdown(); MeshValidator::kill(); MaterialPresetLibrary::kill(); @@ -570,6 +576,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return PropertiesPanelController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("ThemeManager", 1, 0, "ThemeManager", + [](QQmlEngine* engine, QJSEngine* scriptEngine) -> QObject* { + return ThemeManager::qmlInstance(engine, scriptEngine); + }); qmlRegisterSingletonType("AnimationControl", 1, 0, "AnimationControlController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { return AnimationControlController::qmlInstance(engine, nullptr); @@ -664,6 +674,28 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return VATBakerController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "IsometricSpritesController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return IsometricSpritesController::qmlInstance(engine, nullptr); + }); + connect(IsometricSpritesController::instance(), &IsometricSpritesController::outputPathPickRequested, + this, [this](const QString &startPath) { + QTimer::singleShot(0, this, [this, startPath]() { + QString seed = startPath; + if (seed.isEmpty() || QFileInfo(seed).isDir()) + seed = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); + if (seed.isEmpty()) + seed = QDir::homePath(); + if (!seed.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive)) + seed = QDir(seed).filePath(QStringLiteral("isometric_sprites.png")); + + const QString chosen = QFileDialog::getSaveFileName( + this, tr("Save isometric sprite sheet"), seed, + tr("PNG image (*.png)"), nullptr, + QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + emit IsometricSpritesController::instance()->outputPathPicked(chosen); + }); + }); qmlRegisterSingletonType("PropertiesPanel", 1, 0, "MorphAnimationManager", [](QQmlEngine* engine, QJSEngine*) -> QObject* { return MorphAnimationManager::qmlInstance(engine, nullptr); diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 67474c045..88a9f24e8 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -12,6 +12,7 @@ ../qml/UvUnwrapDialog.qml ../qml/QuadRetopoDialog.qml ../qml/SkinWeightsDialog.qml + ../qml/IsometricSpritesDialog.qml ../qml/qmldir ../qml/ThemedButton.qml ../qml/ThemedCheckBox.qml From 1c0c609e4c9cb1b0aa2b56e5868d79d7a2d74b37 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 20 Jun 2026 17:37:25 -0400 Subject: [PATCH 2/3] Fix CI link: add IsometricSpritesController to test common library. mainwindow.cpp references the controller in qtmesh_test_common; link the implementation so UnitTests builds on Linux CI. Co-authored-by: Cursor --- tests/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8f926f177..7444abda5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -108,6 +108,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexColorBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBaker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATBakerController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/IsometricSpritesController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/VATShaderEmitter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MinimalEXRWriter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MorphAnimationManager.cpp @@ -288,6 +289,7 @@ if(BUILD_TESTS) ${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/IsometricSpritesController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditableMesh.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorModeController.h From e5f4fcb35bd994292f0d70cc22a1fa0e8dbe7ee0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 20 Jun 2026 17:47:29 -0400 Subject: [PATCH 3/3] Address isometric UI review feedback and harden export paths. CppOwnership for the QML singleton, full selection restore after export, shared save-seed normalization, safer scene-node visibility checks, keyboard Browse/Export buttons, and expanded controller/MCP test coverage. Co-authored-by: Cursor --- qml/IsometricSpritesDialog.qml | 4 ++ src/IsometricSpritesController.cpp | 48 +++++++++++++++---- src/IsometricSpritesController.h | 3 ++ src/IsometricSpritesController_test.cpp | 12 +++++ ...GenerateIsometricSprites_coverage_test.cpp | 6 ++- src/ModelIsometricRenderer.cpp | 7 ++- src/ModelIsometricRenderer_test.cpp | 3 ++ src/mainwindow.cpp | 15 +++--- 8 files changed, 76 insertions(+), 22 deletions(-) diff --git a/qml/IsometricSpritesDialog.qml b/qml/IsometricSpritesDialog.qml index b2a521ccb..8a39b3a8f 100644 --- a/qml/IsometricSpritesDialog.qml +++ b/qml/IsometricSpritesDialog.qml @@ -103,6 +103,10 @@ Window { signal clicked() implicitWidth: btnLabel.implicitWidth + 16 Layout.preferredWidth: Math.max(90, implicitWidth) + activeFocusOnTab: buttonEnabled + Keys.onSpacePressed: if (buttonEnabled) btn.clicked() + Keys.onReturnPressed: if (buttonEnabled) btn.clicked() + Keys.onEnterPressed: if (buttonEnabled) btn.clicked() height: 26 radius: 3 color: btnMa.containsMouse && buttonEnabled diff --git a/src/IsometricSpritesController.cpp b/src/IsometricSpritesController.cpp index a52e82a53..4d7f46a98 100644 --- a/src/IsometricSpritesController.cpp +++ b/src/IsometricSpritesController.cpp @@ -22,8 +22,10 @@ IsometricSpritesController *IsometricSpritesController::instance() return s_instance; } -IsometricSpritesController *IsometricSpritesController::qmlInstance(QQmlEngine *, QJSEngine *) +IsometricSpritesController *IsometricSpritesController::qmlInstance(QQmlEngine *engine, QJSEngine *) { + if (engine) + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); return instance(); } @@ -113,17 +115,31 @@ void IsometricSpritesController::requestOutputPathPick(const QString &startPath) emit outputPathPickRequested(startPath); } -QString IsometricSpritesController::chooseOutputPath(const QString &startPath) +QString IsometricSpritesController::normalizedSaveSeed(const QString &startPath) { - SentryReporter::addBreadcrumb("ui.action", - QStringLiteral("Isometric sprite save dialog opened (seed=%1)").arg(startPath)); - QString seed = startPath; - if (seed.isEmpty() || !QFileInfo(seed).isDir()) + if (seed.isEmpty() || QFileInfo(seed).isDir()) { seed = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); - if (seed.isEmpty()) - seed = QDir::homePath(); - seed = QDir(seed).filePath(QStringLiteral("isometric_sprites.png")); + if (seed.isEmpty()) + seed = QDir::homePath(); + return QDir(seed).filePath(QStringLiteral("isometric_sprites.png")); + } + if (seed.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive)) + return seed; + + const QFileInfo fi(seed); + if (fi.isDir()) + return QDir(seed).filePath(QStringLiteral("isometric_sprites.png")); + if (fi.suffix().isEmpty()) + return fi.absoluteFilePath() + QStringLiteral(".png"); + return fi.absoluteFilePath(); +} + +QString IsometricSpritesController::chooseOutputPath(const QString &startPath) +{ + const QString seed = normalizedSaveSeed(startPath); + SentryReporter::addBreadcrumb("ui.action", + QStringLiteral("Isometric sprite save dialog opened (seed=%1)").arg(seed)); QApplication::processEvents(); QWidget *parent = QApplication::activeWindow(); @@ -186,6 +202,10 @@ QVariantMap IsometricSpritesController::exportSelected(const QString &outputPath if (entityList.isEmpty()) return fail(QStringLiteral("No mesh selected")); + const QList prevNodes = sel->getNodesSelectionList(); + const QList prevEntities = sel->getEntitiesSelectionList(); + const QList prevSubEntities = sel->getSubEntitiesSelectionList(); + const QString anim = animationName.trimmed(); const int frameCount = anim.isEmpty() ? 1 : frames; @@ -224,10 +244,18 @@ QVariantMap IsometricSpritesController::exportSelected(const QString &outputPath // renderToGrid clears the selection for a clean capture — restore it. sel->clear(); - for (Ogre::Entity *entity : entityList) { + for (Ogre::SceneNode *node : prevNodes) { + if (node) + sel->append(node); + } + for (Ogre::Entity *entity : prevEntities) { if (entity) sel->append(entity); } + for (Ogre::SubEntity *sub : prevSubEntities) { + if (sub) + sel->append(sub); + } setExporting(false); diff --git a/src/IsometricSpritesController.h b/src/IsometricSpritesController.h index c49ae9b69..37b31a598 100644 --- a/src/IsometricSpritesController.h +++ b/src/IsometricSpritesController.h @@ -38,6 +38,9 @@ class IsometricSpritesController : public QObject /// Direct save dialog (tests / headless). GUI should prefer requestOutputPathPick(). Q_INVOKABLE QString chooseOutputPath(const QString &startPath = QString()); + /// Normalize a browse seed into a sensible default `.png` save path. + static QString normalizedSaveSeed(const QString &startPath); + /// Render the current selection to an isometric directions×frames PNG. /// Empty `animationName` → static mesh (one frame per direction). /// Returns `{ ok, outputPath, error, directions, frames, sheetWidth, sheetHeight }`. diff --git a/src/IsometricSpritesController_test.cpp b/src/IsometricSpritesController_test.cpp index bb22c9b0e..577dff277 100644 --- a/src/IsometricSpritesController_test.cpp +++ b/src/IsometricSpritesController_test.cpp @@ -63,3 +63,15 @@ TEST(IsometricSpritesControllerStandalone, RequestOutputPathPickEmitsSignal) EXPECT_EQ(pickRequested.count(), 1); EXPECT_EQ(pickRequested.first().at(0).toString(), QStringLiteral("/tmp/seed.png")); } + +TEST(IsometricSpritesControllerStandalone, NormalizedSaveSeedPreservesFileLikePath) +{ + const QString seed = IsometricSpritesController::normalizedSaveSeed(QStringLiteral("/tmp/my_sprite")); + EXPECT_EQ(seed, QStringLiteral("/tmp/my_sprite.png")); +} + +TEST(IsometricSpritesControllerStandalone, NormalizedSaveSeedDefaultsWhenEmpty) +{ + const QString seed = IsometricSpritesController::normalizedSaveSeed(QString()); + EXPECT_TRUE(seed.endsWith(QStringLiteral("isometric_sprites.png"))); +} diff --git a/src/MCPServerGenerateIsometricSprites_coverage_test.cpp b/src/MCPServerGenerateIsometricSprites_coverage_test.cpp index 2f6dfe2af..fef4f50fc 100644 --- a/src/MCPServerGenerateIsometricSprites_coverage_test.cpp +++ b/src/MCPServerGenerateIsometricSprites_coverage_test.cpp @@ -90,6 +90,7 @@ class MCPServerGenerateIsometricSpritesCoverageTest : public ::testing::Test ASSERT_NE(app, nullptr); ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + ASSERT_TRUE(canLoadMeshFiles()) << "Mesh resources unavailable in test environment"; createStandardOgreMaterials(); server = std::make_unique(); @@ -137,8 +138,11 @@ TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, MissingFileReturnsError) TEST_F(MCPServerGenerateIsometricSpritesCoverageTest, NonexistentFileReturnsError) { + const QString missing = tmp.filePath(QStringLiteral("definitely_missing_mesh.fbx")); + ASSERT_FALSE(QFile::exists(missing)); + QJsonObject args; - args["file"] = QStringLiteral("/nonexistent/missing_mesh.fbx"); + args["file"] = missing; args["output"] = tmp.filePath(QStringLiteral("out.png")); const QJsonObject result = server->callTool(QStringLiteral("generate_isometric_sprites"), args); EXPECT_TRUE(isoIsError(result)); diff --git a/src/ModelIsometricRenderer.cpp b/src/ModelIsometricRenderer.cpp index 9b39209e1..a303371fd 100644 --- a/src/ModelIsometricRenderer.cpp +++ b/src/ModelIsometricRenderer.cpp @@ -84,9 +84,8 @@ class EditorCaptureGuard { if (Manager::getSingletonPtr() && Manager::getSingleton()->hasSceneNode("GridLine_node")) { m_gridNode = Manager::getSingleton()->getSceneNode("GridLine_node"); if (m_gridNode) { - m_gridWasVisible = m_gridNode->getAttachedObject(0) - ? m_gridNode->getAttachedObject(0)->getVisible() - : true; + m_gridWasVisible = m_gridNode->numAttachedObjects() == 0 + || m_gridNode->getAttachedObject(0)->getVisible(); m_gridNode->setVisible(false); } } @@ -97,7 +96,7 @@ class EditorCaptureGuard { continue; if (Ogre::SceneNode *node = other->getParentSceneNode()) { const bool wasVisible = - !node->getAttachedObject(0) || node->getAttachedObject(0)->getVisible(); + node->numAttachedObjects() == 0 || node->getAttachedObject(0)->getVisible(); if (wasVisible) { m_hiddenNodes.emplace_back(node, true); node->setVisible(false); diff --git a/src/ModelIsometricRenderer_test.cpp b/src/ModelIsometricRenderer_test.cpp index 80d547122..3fe50dd29 100644 --- a/src/ModelIsometricRenderer_test.cpp +++ b/src/ModelIsometricRenderer_test.cpp @@ -117,6 +117,9 @@ TEST_F(ModelIsometricRendererTest, StaticGridDimensions) } const QImage sheet = ModelIsometricRenderer::composeDirectionGrid(grid); + ASSERT_FALSE(sheet.isNull()); + ASSERT_GT(sheet.width(), 0); + ASSERT_GT(sheet.height(), 0); EXPECT_EQ(sheet.width(), 64); EXPECT_EQ(sheet.height(), 48 * 4); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 00a559357..62262801c 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -681,18 +681,19 @@ void MainWindow::initToolBar() connect(IsometricSpritesController::instance(), &IsometricSpritesController::outputPathPickRequested, this, [this](const QString &startPath) { QTimer::singleShot(0, this, [this, startPath]() { - QString seed = startPath; - if (seed.isEmpty() || QFileInfo(seed).isDir()) - seed = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation); - if (seed.isEmpty()) - seed = QDir::homePath(); - if (!seed.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive)) - seed = QDir(seed).filePath(QStringLiteral("isometric_sprites.png")); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Isometric sprite save dialog requested")); + const QString seed = IsometricSpritesController::normalizedSaveSeed(startPath); const QString chosen = QFileDialog::getSaveFileName( this, tr("Save isometric sprite sheet"), seed, tr("PNG image (*.png)"), nullptr, QFileDialog::DontUseNativeDialog | QFileDialog::DontUseCustomDirectoryIcons); + SentryReporter::addBreadcrumb( + chosen.isEmpty() ? QStringLiteral("ui.action") : QStringLiteral("file.export"), + chosen.isEmpty() ? QStringLiteral("Isometric sprite save dialog cancelled") + : QStringLiteral("Isometric sprite save dialog accepted: %1") + .arg(chosen)); emit IsometricSpritesController::instance()->outputPathPicked(chosen); }); });