Skip to content

Commit 465af7f

Browse files
Merge pull request #741 from fernandotonon/feature/isometric-sprites-724
Isometric 8-direction sprite export (#724)
2 parents 1ce6dfd + fbc3da9 commit 465af7f

22 files changed

Lines changed: 1782 additions & 19 deletions

.github/actions/qtmesh/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ description: 'Run qtmesh CLI for 3D mesh operations (info, convert, fix, anim, s
33

44
inputs:
55
command:
6-
description: 'Subcommand: info, fix, convert, anim, validate, lod, pose, turntable, scan, material, optimize, …'
6+
description: 'Subcommand: info, fix, convert, anim, validate, lod, pose, turntable, isometric, scan, material, optimize, …'
77
required: true
88
input-file:
99
description: 'Directory or file to scan (relative to workspace). Defaults to .'

CLAUDE.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl # export singl
5555
qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # export N evenly spaced frames
5656
qtmesh turntable model.fbx -o turntable.png # PNG sprite sheet (12 frames default)
5757
qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-height 25
58+
qtmesh isometric model.fbx -o iso.png # 8-direction static sprite grid (rows=directions)
59+
qtmesh isometric model.fbx --resolution 256 -o iso.png # square 256px cells
60+
qtmesh isometric model.fbx --animation "Walk" --frames 8 -o iso.png # 8×8 animated atlas
61+
qtmesh isometric model.fbx -o iso.png --padding 1.5 # zoom out (auto-fit × 1.5)
62+
qtmesh isometric model.fbx -o iso.png --camera-distance 5 # fixed orbit distance
5863
qtmesh validate model.fbx # validate mesh (exit 1 if errors found)
5964
qtmesh validate model.fbx --json # validation results as JSON
6065
qtmesh lod model.fbx --info # show LOD levels
@@ -97,7 +102,7 @@ qtmesh uv model.fbx --unwrap -o unwrapped.glb # xatlas auto-UV unwrap (#400). N
97102
qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb # write into UV1 (lightmap workflow)
98103
```
99104

100-
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.
105+
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.
101106

102107
If Xcode SDK is updated, clear CMake cache (`rm build_local/CMakeCache.txt`) and reconfigure.
103108

@@ -200,7 +205,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas
200205
### CLI Pipeline
201206

202207
- **CLIPipeline** (`src/CLIPipeline.h/cpp`): Headless command-line interface for mesh operations. All static methods — entry point is `CLIPipeline::run(argc, argv)`.
203-
- 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`.
208+
- 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`.
204209
- Activated via `qtmesh` symlink (created at build time), `--cli` flag, or recognized subcommand as first arg.
205210
- 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.
206211
- **AnimationMerger** (`src/AnimationMerger.h/cpp`): Public `renameAnimation()` static method used by both CLI and GUI for animation renaming.
@@ -244,6 +249,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas
244249
- **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%.
245250
- **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.<i>` 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.
246251
- **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`.
252+
- **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`.
247253
- **FBX LOD export gotcha**: `FBXExporter` prefers the cached `qtme.faces.<i>` 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.
248254

249255
## Development Guidelines

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0)
1313
cmake_policy(SET CMP0005 NEW)
1414
cmake_policy(SET CMP0048 NEW) # manages project version
1515

16-
project(QtMeshEditor VERSION 3.6.0 LANGUAGES C CXX)
16+
project(QtMeshEditor VERSION 3.7.0 LANGUAGES C CXX)
1717
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")
1818

1919
set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")

README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act
3535
**Versioning**
3636

3737
- **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.
38-
- **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`.
38+
- **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`.
3939

4040
Pinned workflow template (action + `ghcr.io` image aligned):
4141

@@ -53,10 +53,10 @@ jobs:
5353
- uses: actions/checkout@v4
5454

5555
- name: Run QtMesh scan
56-
uses: fernandotonon/QtMeshEditor@3.6.0
56+
uses: fernandotonon/QtMeshEditor@3.7.0
5757
with:
5858
command: scan
59-
image-tag: "3.6.0"
59+
image-tag: "3.7.0"
6060
env:
6161
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
6262
```
@@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/
8181

8282
```yaml
8383
# Validate a specific mesh
84-
- uses: fernandotonon/QtMeshEditor@3.6.0
84+
- uses: fernandotonon/QtMeshEditor@3.7.0
8585
with:
8686
command: validate
8787
input-file: ./models/character.fbx
88-
image-tag: "3.6.0"
88+
image-tag: "3.7.0"
8989
9090
# Convert FBX → glTF
91-
- uses: fernandotonon/QtMeshEditor@3.6.0
91+
- uses: fernandotonon/QtMeshEditor@3.7.0
9292
with:
9393
command: convert
9494
input-file: ./models/character.fbx
9595
output-file: ./output/character.gltf2
96-
image-tag: "3.6.0"
96+
image-tag: "3.7.0"
9797
9898
# Resample Mixamo animations (200+ keyframes → 30)
99-
- uses: fernandotonon/QtMeshEditor@3.6.0
99+
- uses: fernandotonon/QtMeshEditor@3.7.0
100100
with:
101101
command: anim
102102
input-file: ./animations/dance.fbx
103103
output-file: ./output/dance_optimized.fbx
104104
options: --resample 30
105-
image-tag: "3.6.0"
105+
image-tag: "3.7.0"
106106
107107
# Get mesh info as JSON
108-
- uses: fernandotonon/QtMeshEditor@3.6.0
108+
- uses: fernandotonon/QtMeshEditor@3.7.0
109109
id: info
110110
with:
111111
command: info
112112
input-file: ./models/character.fbx
113113
options: --json
114-
image-tag: "3.6.0"
114+
image-tag: "3.7.0"
115115
116116
# Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref)
117117
docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ branding:
88

99
inputs:
1010
command:
11-
description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose, turntable'
11+
description: 'Subcommand: scan, info, validate, convert, fix, anim, lod, pose, turntable, isometric'
1212
required: true
1313
input-file:
1414
description: 'Directory or file to scan (relative to workspace). Defaults to . (workspace root).'

scripts/sync-doc-versions-from-cmake.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ apply_perl_replace() {
8484
QTMESH_DOC_VERSION="${VERSION}" perl -i -pe \
8585
'BEGIN { $v = $ENV{QTMESH_DOC_VERSION}; } s/(?<!`)(image-tag:\s*")(\d+\.\d+\.\d+)(")(?!`)/$1 . $v . $3/ge' \
8686
"$f"
87+
QTMESH_DOC_VERSION="${VERSION}" perl -i -pe \
88+
'BEGIN { $v = $ENV{QTMESH_DOC_VERSION}; } s/(currently \*\*)\d+\.\d+\.\d+(\*\*)/$1 . $v . $2/ge' \
89+
"$f"
8790
}
8891

8992
if [[ "${CHECK}" -eq 1 ]]; then

src/AppLaunchHandler.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ bool isCliSubcommand(const QString& arg)
1717
static const QStringList kSubcommands = {
1818
QStringLiteral("info"), QStringLiteral("fix"), QStringLiteral("convert"),
1919
QStringLiteral("anim"), QStringLiteral("validate"), QStringLiteral("lod"),
20-
QStringLiteral("pose"), QStringLiteral("turntable"), QStringLiteral("scan"),
20+
QStringLiteral("pose"), QStringLiteral("turntable"), QStringLiteral("isometric"), QStringLiteral("scan"),
2121
QStringLiteral("material"), QStringLiteral("pack-textures"),
2222
QStringLiteral("normal-from-height"), QStringLiteral("memory"),
2323
QStringLiteral("analyze"), QStringLiteral("vertex-cache"),

0 commit comments

Comments
 (0)