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
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.36.0 LANGUAGES C CXX)
project(QtMeshEditor VERSION 3.36.1 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 @@ -30,7 +30,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.36.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.36.1**). 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 @@ -48,10 +48,10 @@ jobs:
- uses: actions/checkout@v4

- name: Run QtMesh scan
uses: fernandotonon/QtMeshEditor@3.36.0
uses: fernandotonon/QtMeshEditor@3.36.1
with:
command: scan
image-tag: "3.36.0"
image-tag: "3.36.1"
env:
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
```
Expand All @@ -76,37 +76,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/

```yaml
# Validate a specific mesh
- uses: fernandotonon/QtMeshEditor@3.36.0
- uses: fernandotonon/QtMeshEditor@3.36.1
with:
command: validate
input-file: ./models/character.fbx
image-tag: "3.36.0"
image-tag: "3.36.1"

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

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

# Get mesh info as JSON
- uses: fernandotonon/QtMeshEditor@3.36.0
- uses: fernandotonon/QtMeshEditor@3.36.1
id: info
with:
command: info
input-file: ./models/character.fbx
options: --json
image-tag: "3.36.0"
image-tag: "3.36.1"

# Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref)
# The image is multi-arch (linux/amd64 + linux/arm64), so it runs natively on
Expand Down
32 changes: 18 additions & 14 deletions src/AnimationMerger.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4299,9 +4299,13 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
// per role, so mis-named chest-height "legs" fed it garbage). The mocap
// path (cmuLibraryHandedness=false) keeps its historical mapping.
if (cmuLibraryHandedness) {
const Ogre::Vector3 up(0, 1, 0);
const Ogre::Vector3 fwd(0, 0, yaw180 ? -1.0f : 1.0f);
const Ogre::Vector3 trueLeft = up.crossProduct(fwd);
// The side decision is deliberately FACING-BLIND world-X — exactly
// the rule the old compensator applied to every validated result:
// canonical-LEFT roles must end on the bones at world +X, regardless
// of which way the mesh faces (facing is handled by the clip-level
// yaw180, never by the side mapping; a facing-aware trueLeft variant
// was tried here and INVERTED backward-facing rigs — user-reported).
const Ogre::Vector3 trueLeft(1, 0, 0);

// BIND pose positions — the current pose may be mid-animation
// (crossed limbs would flip the vote). Same reset the old
Expand All @@ -4323,10 +4327,14 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
}
const float bodyH = std::max(1e-6f, hiY - loY);

// ---- (b) arm/leg altitude guard --------------------------------
// ---- (b) leg-altitude guard ------------------------------------
// A LEG-role bone bound well ABOVE the hips is a mis-named arm
// segment: strip it (remembering its segment for the rescue below).
// An ARM-role bone well BELOW the hips is stripped outright.
// The symmetric arm-below-hips strip was deliberately DROPPED after
// review: a relaxed/hanging bind pose legitimately puts wrists below
// the hips, and stripped arm roles have no rescue — the hands would
// simply freeze. Leg-named-arms is the observed real-world failure;
// arm-named-legs is not.
// seg: 0 = upper limb root, 1 = middle, 2 = tip; -1 = buttock/collar.
struct Rescue { int bone; int seg; };
std::vector<Rescue> rescue;
Expand All @@ -4336,16 +4344,13 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
const int c = boneToCanon[i];
if (c < 0) continue;
const bool legRole = (c >= 14 && c <= 21);
const bool armRole = (c >= 6 && c <= 13);
if (legRole && bonePos[i].y > hipY + 0.10f * bodyH) {
int seg = -1;
if (c == 15 || c == 19) seg = 0;
else if (c == 16 || c == 20) seg = 1;
else if (c == 17 || c == 21) seg = 2;
rescue.push_back({i, seg});
boneToCanon[i] = -1;
} else if (armRole && bonePos[i].y < hipY - 0.10f * bodyH) {
boneToCanon[i] = -1; // "arm" below the hips — drop
}
}
}
Expand Down Expand Up @@ -4378,11 +4383,10 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
if (!roleHas[pr[0]] || !roleHas[pr[1]]) continue;
side += (rolePos[pr[0]] - rolePos[pr[1]]).dotProduct(trueLeft);
}
// One-permutation convention: canonical LEFT roles must end on the
// bones at +trueLeft (anatomical left) — that is the state the old
// compensator produced on the validated Mixamo case (named-left at
// -trueLeft, one swap). So: swap when the named pairs sit at
// -trueLeft; an anatomically-named rig (UniRig) needs none.
// Swap when the named-left roles sit at NEGATIVE world X (they must
// end at +X). Mixamo measures -2.36 → one swap (identical to the old
// compensator); the -Z-facing UniRig orc measures -0.44 → swap; a
// +X-named rig needs none.
constexpr double kExpectedSideSign = +1.0;
if (qEnvironmentVariableIsSet("QTMESH_T2M_SIDE_DEBUG"))
fprintf(stderr, "[t2m] side score %.4f (expected sign %+.0f)\n",
Expand Down Expand Up @@ -4434,7 +4438,7 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip(
if (rc.seg < 0) continue;
const float lat = (bonePos[rc.bone] - bonePos[hipIdx])
.dotProduct(trueLeft);
const int sideIdx = lat >= 0.0f ? 1 : 0; // left roles at +trueLeft
const int sideIdx = lat >= 0.0f ? 1 : 0; // left roles at +X
if (armTaken[sideIdx]) continue;
boneToCanon[rc.bone] = kArmSeg[sideIdx][rc.seg];
++rescued;
Expand Down
15 changes: 8 additions & 7 deletions src/AnimationMerger_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1946,9 +1946,9 @@ TEST_F(AnimationMergerTest, ApplyMotionClipDetectsMirroredSideNaming)

// ONE composed permutation (replaces the old compensateCanonicalHandedness
// on the library path): canonical-LEFT roles must end on the bones at
// +trueLeft (anatomical left). A Mixamo-convention rig — named-left at
// MINUS up×fwd (negative X when facing +Z) — therefore gets exactly ONE
// swap (what the old compensator did on the validated case).
// world +X — the facing-blind rule of the old compensator, whose output
// every library clip was validated against. A rig with named-left at -X
// therefore gets exactly ONE swap.
Ogre::Entity* norm = build("sidenorm", -1.0f);
ASSERT_NE(norm, nullptr);
const auto quats = identityClip(3);
Expand All @@ -1970,14 +1970,15 @@ TEST_F(AnimationMergerTest, ApplyMotionClipDetectsMirroredSideNaming)
EXPECT_FALSE(resMir.sideSwapApplied)
<< "anatomically-named rig needs no permutation";

// yaw180 flips forward and therefore trueLeft: the SAME rig evaluated as
// backward-facing has its named-left at -trueLeft — swap expected.
// The side decision is FACING-BLIND (world-X, like the old compensator
// whose results the library is calibrated against): yaw180 must NOT
// change it. A facing-aware variant inverted backward-facing rigs.
const auto resMirYaw = AnimationMerger::applyMotionClip(
mir->getSkeleton(), "sideclip2", quats, 30, /*worldFrame=*/true,
srcRestWorld(), false, 8, /*yaw180=*/true, canonRestDirs());
ASSERT_TRUE(resMirYaw.ok) << resMirYaw.error.toStdString();
EXPECT_TRUE(resMirYaw.sideSwapApplied)
<< "backward-facing flips trueLeft — the swap is needed again";
EXPECT_FALSE(resMirYaw.sideSwapApplied)
<< "the side decision is facing-blind — yaw180 must not flip it";

auto* sm = Manager::getSingleton()->getSceneMgr();
sm->destroyEntity(norm);
Expand Down
6 changes: 5 additions & 1 deletion src/MotionLibrary.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,11 @@ bool MotionLibrary::parse(const QByteArray& json)
}
clip.quality = static_cast<float>(
std::clamp(co.value("quality").toDouble(1.0), 0.0, 1.0));
clip.uprightness = meanChestLean(clip.quats);
// meanChestLean reads joint 2 as a WORLD orientation — only valid
// for world-frame libraries (schema v3+). Legacy local-frame clips
// keep a neutral 0 so the posture penalty can never misfire on a
// parent-relative chest value.
clip.uprightness = m_worldFrame ? meanChestLean(clip.quats) : 0.0f;
if (clip.frames > 0 && !clip.action.isEmpty())
m_clips.push_back(std::move(clip));
}
Expand Down
2 changes: 1 addition & 1 deletion website/src/hooks/useQtmeshActionRef.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';

const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest';
const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.36.0';
const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.36.1';
const CACHE_KEY = 'qtmesh.actionRef.cache.v1';
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;

Expand Down
Loading