diff --git a/CMakeLists.txt b/CMakeLists.txt index fc5f2bd0..43a18f4f 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 3.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}\"") diff --git a/README.md b/README.md index 4fc7661d..9a127bf0 100755 --- a/README.md +++ b/README.md @@ -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): @@ -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 }} ``` @@ -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 diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index 4155d659..a50e7895 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -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 @@ -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; @@ -4336,7 +4344,6 @@ 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; @@ -4344,8 +4351,6 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip( 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 } } } @@ -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", @@ -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; diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 471e1661..e85a2101 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -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); @@ -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); diff --git a/src/MotionLibrary.cpp b/src/MotionLibrary.cpp index 86c92166..50b36cd4 100644 --- a/src/MotionLibrary.cpp +++ b/src/MotionLibrary.cpp @@ -236,7 +236,11 @@ bool MotionLibrary::parse(const QByteArray& json) } clip.quality = static_cast( 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)); } diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index 811d5209..11cf808e 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.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;