Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ qtmesh anim model.fbx --decimate-step 5 -o lighter.fbx # keep every 5th keyfram
qtmesh anim model.fbx --resample 30 --animation "Walk" -o out.fbx # resample specific animation
qtmesh anim model.fbx --bake-fps 30 -o uniform.fbx # re-grid every track to uniform 30 FPS
qtmesh anim model.fbx --bake-fps 60 --animation "Run" -o out.fbx # bake one animation at 60 FPS
qtmesh anim model.fbx --in-between --gap-frames 30 -o filled.fbx # AI in-betweening: fill the clip with 30 predicted keyframes (RMIB ONNX; smooth spline fallback) (#409)
qtmesh anim model.fbx --in-between --gap-frames 12 --start-time 0.5 --end-time 1.5 --animation "Jump" -o out.fbx # fill a specific window of one animation
qtmesh anim model.fbx --in-between --gap-frames 12 --no-model -o out.fbx # force the deterministic spline fallback (skip the ML model)
qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl # export single frame
qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl # export N evenly spaced frames
qtmesh turntable model.fbx -o turntable.png # PNG sprite sheet (12 frames default)
Expand Down Expand Up @@ -281,6 +284,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.<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.
- **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`.
- **MotionInbetween** (`src/MotionInbetween.h/cpp`, issue #409): AI animation in-betweening — fills the gap between two sparse keyframes with smooth, plausible intermediate poses. The issue proposes **Robust Motion In-betweening** (Harvey et al., Ubisoft, SIGGRAPH 2020), a small transition transformer; like #404/#408 the ML path runs on ONNX Runtime (`#ifdef ENABLE_ONNX`) and is the **third ONNX consumer**. **The spline fallback is first-class** (per the issue's acceptance criteria): `interpolateSpline` (cubic-Hermite with Catmull-Rom tangents for translation/scale, shortest-arc `slerpQuat` for rotation) is Ogre-free, always compiled, and used automatically whenever the binary lacks ONNX, the model is missing/un-downloadable, the skeleton is incompatible with the model, or the run fails — `Result::usedModel` + `fallbackReason` tell the caller which path ran. The core works on flat per-frame pose arrays (channels = bones × 10 DoF: `[tx,ty,tz, qx,qy,qz,qw, sx,sy,sz]`) with a `Channel` layout (`Scalar`/`QuatStart`/`QuatCont`) so it's unit-tested without Ogre/GL. `MotionInbetween::ensureModelBlocking()` downloads `rmib.onnx` on first use to `AppData/ai_models/inbetween/` (override `QTMESH_INBETWEEN_MODEL_BASE_URL` / `QSettings ai/inbetweenModelBaseUrl`; offline guard `QTMESH_INBETWEEN_NO_DOWNLOAD`) — the #408 self-contained pattern, with the `#ifndef ENABLE_ONNX return {}` guard. `AnimationMerger::inbetweenAnimation(skel, animName, t0, t1, gapFrames, modelPath, forceFallback)` is the Ogre adapter: it packs every bracketing node track's start/end pose into ONE predict() call (so the model sees the full skeleton), scatters the predicted per-frame poses back as keyframes at uniform interior times, and returns an `InbetweenResult` (keyframesInserted / tracksAffected / usedModel / fallbackReason). Surfaced via **CLI `qtmesh anim <file> --in-between --gap-frames N [--start-time S] [--end-time S] [--no-model] [--animation NAME] [-o out]`** (`CLIPipeline::cmdAnim`), the **MCP `motion_in_between` tool** (`MCPServer::toolMotionInBetween`, args `{gap_frames, entity_name?, animation_name?, start_time?, end_time?, no_model?}`, registered heavy), and the **dope sheet "AI in-between … Fill gap" control** (`qml/AnimationDopeSheet.qml` → `AnimationControlController::inbetweenWindow`, shown when the selection spans a time window; emits `inbetweenStatus`). Sentry breadcrumb category `ai.assist.in_between`. **Canonical skeleton + retargeting:** the model is trained on a FIXED 22-joint CMU core-body skeleton (C=220), so `AnimationMerger::inbetweenAnimation` maps the entity's track bones onto those 22 roles via `MotionInbetween::canonicalIndexForBone()` (handles Mixamo `mixamorig:*`, generic `L_Shoulder`, and CMU names; rejects finger/toe/face bones; note Mixamo "Shoulder"=clavicle→collar while "Arm"=upper-arm→the CMU shoulder role). When a strong majority (≥¾) of the 22 roles resolve it packs the canonical pose, runs the model, and scatters predictions back to the matched tracks; unmatched/non-bracketed tracks (and rigs that don't resolve enough roles, and non-ONNX builds) use the per-track spline. **Model: ours, trained from scratch on CMU MoCap** (`scripts/export-rmib-onnx.py`, one-time offline dev tool — NOT shipped) — CMU is permissively licensed (commercial-OK), unlike the field-standard LAFAN1 (CC-BY-NC-ND, rejected). Validated: rotation error < half of slerp on held-out CMU motion. **Hosting:** `rmib.onnx` (~13 MB) is live in the [`fernandotonon/QtMeshEditor-models`](https://huggingface.co/fernandotonon/QtMeshEditor-models) HF repo under `inbetween/`, downloads on first use (see `THIRD_PARTY_AI_MODELS.md`).
- **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.<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.

Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.11.0 LANGUAGES C CXX)
project(QtMeshEditor VERSION 3.13.0 LANGUAGES C CXX)
message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}")

set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"")
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.11.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`.
- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.13.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):

Expand All @@ -53,10 +53,10 @@ jobs:
- uses: actions/checkout@v4

- name: Run QtMesh scan
uses: fernandotonon/QtMeshEditor@3.11.0
uses: fernandotonon/QtMeshEditor@3.13.0
with:
command: scan
image-tag: "3.11.0"
image-tag: "3.13.0"
env:
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
```
Expand All @@ -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.11.0
- uses: fernandotonon/QtMeshEditor@3.13.0
with:
command: validate
input-file: ./models/character.fbx
image-tag: "3.11.0"
image-tag: "3.13.0"

# Convert FBX → glTF
- uses: fernandotonon/QtMeshEditor@3.11.0
- uses: fernandotonon/QtMeshEditor@3.13.0
with:
command: convert
input-file: ./models/character.fbx
output-file: ./output/character.gltf2
image-tag: "3.11.0"
image-tag: "3.13.0"

# Resample Mixamo animations (200+ keyframes → 30)
- uses: fernandotonon/QtMeshEditor@3.11.0
- uses: fernandotonon/QtMeshEditor@3.13.0
with:
command: anim
input-file: ./animations/dance.fbx
output-file: ./output/dance_optimized.fbx
options: --resample 30
image-tag: "3.11.0"
image-tag: "3.13.0"

# Get mesh info as JSON
- uses: fernandotonon/QtMeshEditor@3.11.0
- uses: fernandotonon/QtMeshEditor@3.13.0
id: info
with:
command: info
input-file: ./models/character.fbx
options: --json
image-tag: "3.11.0"
image-tag: "3.13.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
Expand Down
36 changes: 36 additions & 0 deletions THIRD_PARTY_AI_MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,42 @@ the binary). Attribution + licenses for the models and their training data:
- Real-ESRGAN x2plus / x4plus from https://github.com/xinntao/Real-ESRGAN —
**BSD-3-Clause**.

## RMIB — animation in-betweening (issue #409)

- **Model:** an RMIB-style (Robust Motion In-betweening) transformer that
predicts intermediate poses between two keyframes, exported to ONNX (~13 MB).
- **Algorithm/paper:** Harvey, Yurick, Nowrouzezahrai, Pal — *"Robust Motion
In-betweening"*, SIGGRAPH 2020 (Ubisoft La Forge). The *algorithm* (a
transition transformer over a fixed skeleton/feature layout) is published and
unencumbered; the app ships a from-scratch ONNX runtime for it
(`src/MotionInbetween.cpp`), not Ubisoft's research code — and the shipped
weights are **our own**, trained from scratch (see below), NOT Ubisoft's.
- **Training data:** **CMU Graphics Lab Motion Capture Database**
(mocap.cs.cmu.edu) — permissively licensed: free to use/modify/redistribute
*including in commercial products*; the only restriction is you may not RESELL
the motion data itself. Credit: mocap.cs.cmu.edu. This is what makes our
weights redistributable under the project's permissive bar — the rest of the
in-betweening field standardizes on **LAFAN1** (Ubisoft LaForge), which is
**CC-BY-NC-ND** (non-commercial / no-derivatives) and was therefore rejected,
same posture as RigNet for #408.
- **Skeleton:** trained on the 22 CMU core-body joints (hips/spine/neck/head +
both arms + both legs). At runtime `MotionInbetween::canonicalIndexForBone()`
maps arbitrary rig bones (Mixamo / generic / CMU naming) onto these 22 roles;
rigs that don't resolve a strong majority fall back to the spline.
- **Export tool:** `scripts/export-rmib-onnx.py` (one-time, offline, NOT shipped
— the app never runs Python). Produces `rmib.onnx` (input `[1,2,220]` →
output `[1,8,220]`).
- **Hosting:** `rmib.onnx` is hosted in the
[`fernandotonon/QtMeshEditor-models`](https://huggingface.co/fernandotonon/QtMeshEditor-models)
HF repo under `inbetween/` and downloads on first use to
`AppData/ai_models/inbetween/` (override `QTMESH_INBETWEEN_MODEL_BASE_URL` /
`QSettings ai/inbetweenModelBaseUrl`; offline guard `QTMESH_INBETWEEN_NO_DOWNLOAD`).
- **Fallback:** when ONNX is disabled, the model can't be fetched, or a rig
doesn't map to the canonical skeleton, the feature uses its deterministic
spline fallback (cubic-Hermite + shortest-arc slerp) — always present, needs
no model, and visibly smoother than naive linear interpolation. The trained
model measurably beats slerp on held-out CMU motion (rotation error < half).

All of the above clear QtMeshEditor's permissive-redistribution bar (MIT app,
distributed via Homebrew / WinGet / Snap / Docker). GPL/CC-BY-NC/unlicensed
models are deliberately excluded (e.g. RigNet was rejected for #408 — GPL code +
Expand Down
Loading
Loading