From 908e7dfe6d555244258122e9ba9f8d27c02b434d Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 11 Jul 2026 22:23:30 -0400 Subject: [PATCH 01/16] feat(anim): Mixamo-style arm-space post-process (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated animations still clip the arms into the torso or splay them too wide on rigs whose proportions differ from the source clip. This adds a user-controlled "arm space" swing, matching Mixamo's Character Arm-Space slider — cheap, intuitive, and it rescues most cases without touching the retarget math. Core — AnimationMerger::adjustArmSpace(skel, animName, degrees): - Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine are untouched. Positive widens both arms, negative tucks them in. - Swings about the torso FORWARD axis (from the target bind frame Ct, reusing the retarget's readTargetBindFrame helper). Ogre keyframes are deltas applied onto the bind pose (NodeAnimationTrack::applyToNode post-multiplies the reset bone), so the world swing S is folded into each keyframe as L*kf with L = Wbind^-1 * S * Wbind — path-agnostic across the direction- and legacy-retarget outputs. - ABSOLUTE + idempotent: a session-scoped (skeleton,anim)->angle map tracks the last-applied value and reverts it before applying the new one, so a slider maps straight to the value and 0 restores the clip bit-exactly (verified: +45 then -45 == base, max quat diff 0.0). Surfaces: - CLI: --arm-space on --generate, plus standalone `qtmesh anim --arm-space --animation -o out`. - MCP: arm_space arg on generate_motion + a standalone adjust_arm_space tool. - GUI: an "Arm space" slider (-30..+45 deg) in the Inspector Animations section, shown for a freshly generated clip, applied on release. - Sentry breadcrumb ai.assist.text_to_motion (arm_space). Unit tests (AnimationMerger_test.cpp): swing angle ~= requested, per-side symmetry, idempotence (20 then 10 == 10; 0 restores), and non-arm bones untouched. Verified end-to-end on the Hip Hop fox via CLI + isometric renders (widen lifts the arms out, tuck pulls them in). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs (noted in the issue). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 + qml/PropertiesPanel.qml | 50 ++++++++++- src/AnimationControlController.cpp | 37 ++++++++- src/AnimationControlController.h | 10 ++- src/AnimationMerger.cpp | 118 ++++++++++++++++++++++++++ src/AnimationMerger.h | 26 ++++++ src/AnimationMerger_test.cpp | 128 +++++++++++++++++++++++++++++ src/CLIPipeline.cpp | 67 ++++++++++++++- src/CLIPipeline.h | 3 +- src/MCPServer.cpp | 94 +++++++++++++++++++++ src/MCPServer.h | 1 + 11 files changed, 529 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8a14e59..fc8c1e99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ qtmesh anim model.fbx --in-between --gap-frames 12 --no-model -o out.fbx # forc qtmesh anim model.fbx --dump-canonical clips.json # #839: extract every skeletal animation onto the 22-joint canonical skeleton (world-frame quats, bind-geometry-derived axis conjugation) — feeds scripts/build-motion-library-v5.py qtmesh anim rigged.fbx --generate "walking confidently" -o out.glb # text-to-motion (#411, experimental): match a permissive CMU clip → retarget onto the rig. Actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle (+ synonyms). Library downloads on first use; needs a humanoid rig qtmesh anim rigged.fbx --generate "jump" --duration 2 -o out.glb # retime the template to N seconds +qtmesh anim rigged.fbx --generate "walk" --arm-space 35 -o out.glb # #854: Mixamo-style arm-space — widen (+) / tuck (−) the arms as a post-process +qtmesh anim rigged.glb --arm-space -20 --animation generated_walk -o out.glb # standalone: re-adjust an existing clip's arm space (absolute + idempotent) 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) @@ -374,6 +376,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **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 --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`). - **MotionLibrary / text-to-motion** (`src/MotionLibrary.h/cpp`, issue #411, experimental): generate a skeletal animation from a text prompt. **The #411 spike (see `docs/TEXT_TO_MOTION_SPIKE_411.md`) proved a from-scratch GENERATIVE model (MDM-style) collapses to a static pose without multi-day ML effort, and all off-the-shelf models (MDM/T2M-GPT/MotionGPT) train on AMASS-derived HumanML3D/KIT-ML = non-commercial (the LAFAN1/ShapeNet wall again).** So the SHIPPED feature is a **template-clip MVP**: a curated library of permissive **CMU MoCap** clips (commercial-OK, same source as #409 RMIB), matched to the prompt by action keyword + synonyms (`MotionLibrary::matchPrompt`), then retargeted onto the user's rig via `AnimationMerger::applyMotionClip` → `MotionInbetween::canonicalIndexForBone` (the SAME 22-joint canonical mapping as #409). `MotionLibrary` is Ogre-free + unit-tested (`MotionLibrary_test.cpp`): parses `qtmesh-motion-library-v1`/`v2` JSON (per-frame, per-joint canonical quats; v2 adds an optional 22-entry `cmuRestWorld` block) + keyword matching. Library downloads on first use to `AppData/ai_models/motion/` (override `QTMESH_MOTION_LIBRARY_BASE_URL` / `QSettings ai/motionLibraryBaseUrl`; offline guard `QTMESH_MOTION_NO_DOWNLOAD`) — built offline by `scripts/build-motion-library.py` (10 actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle, ~0.9 MB), hosted on the [`fernandotonon/QtMeshEditor-models`](https://huggingface.co/fernandotonon/QtMeshEditor-models) HF repo under `motion/`. **Retarget (`applyMotionClip`) — the part that makes it look right:** the CMU clip stores each joint's LOCAL (parent-relative) rotation with rest ≈ identity (the rest DIRECTION is in the BVH bone offsets, captured as the v2 `cmuRestWorld` per-joint world-rest `Wcmu`). The exact per-bone formula is `local(f) = parentWorld⁻¹ · (Wcmu · clip(f) · Wcmu⁻¹) · parentWorld · bind`, where `bind` = the rig's STANDING pose harvested from frame-0 of its existing animation (Mixamo bone rest is identity — the standing pose lives in the anim, NOT in `getInitial{Orientation,Position}` which is inflated and would stretch the mesh), and the root (hip) is locked to the standing pose (CMU bakes whole-body facing into the root). The `Wcmu·clip·Wcmu⁻¹` conjugation is the CMU↔target change-of-basis that cancels the per-bone ROLL twist between rigs with different bone axes (Mixamo arms point down their length / sideways; UniRig is axis-aligned). v1 libraries (no `cmuRestWorld`) fall back to `Wcmu`=identity (parent-world transport only — direction-correct, residual roll). Writes rotation-only keyframes (translation/scale stay at the standing pose, preserving rig proportions) and requires ≥½ of the 22 canonical roles to resolve (else fails — not a humanoid rig). Render-verified on the Rumba (Mixamo) rig via the isometric loop: walk = upright stride with arms hanging+swinging; wave = upright + natural. Surfaced via **CLI `qtmesh anim --generate "" [--duration N] [-o out]`** (`CLIPipeline::cmdAnimGenerate`), the **MCP `generate_motion` tool** (`MCPServer::toolGenerateMotion`, args `{prompt, entity_name?, duration?, output_path?}`, registered heavy), and the **Animation panel "Generate from text" control** (`qml/AnimationControlPanel.qml` → `AnimationControlController::generateMotion`, emits `generateMotionStatus`). Sentry breadcrumb `ai.assist.text_to_motion`. **v4 library** (July 2026): clips are the trial's ACTIVE window (max motion energy, start snapped to a calm near-neutral frame — the retarget deltas against clip frame 0), replacing first-4s slices that mostly captured idle lead-ins; 13 actions (adds sit/throw/boxing; 'idle' now a real wait trial — the old 69_01 source walked; 'dance' is salsa — ballet pirouettes fold under the locked root). **Generative path (opt-in `--model` / `model:true` / GUI checkbox): `MotionGenerator` + `motion/t2m.onnx`** — a CVAE transformer trained from scratch on the same CMU source (`scripts/prep-t2m-v4.py` + `scripts/train-t2m-onnx-v4.py`, offline): 30fps WORLD-frame windows w/ neutral starts (the v3 model trained on raw-120fps 0.33s local-frame windows and folded/flailed at 3.5x real velocity), absolute-pose decoder (no error-accumulating delta-cumsum), per-sample + rotation-space (geodesic) velocity matching, derived-local supervision (parent^-1*child — the exact quantity applyMotionClip renders; world-only losses let spine-chain errors stack into a visible fold), and z=0 latent-dropout supervision (the app infers with seed=zeros; an unsupervised z=0 is out-of-distribution for a low-beta CVAE). The vocab json declares `frame:world` + `fps`, read by `MotionGenerator::generate` → `Result::worldFrame` → applyMotionClip, so model clips ride the same world retarget as v3 template clips. Template library stays the default + automatic fallback. **Quality limit:** the model's z=0 output is smooth/upright but gentler than real clips (conditional-mean effect; the medoid-exemplar alternative is crisper numerically but renders twisted — `--z0-target` flag documents both); the template path is the quality bar. +- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on a generated clip to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Idempotent + absolute**: a session-scoped `std::map<(skeleton,anim),float>` tracks the last-applied angle; re-applying reverts it first (delta = new−stored), so a slider maps directly to the value and `adjustArmSpace(0)` restores the clip bit-exactly (verified: `+45` then `−45` == base, max quat diff 0.0). Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace`), and the **Inspector Animations "Arm space" slider** (−30…+45°, `qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`, applies on release to the last generated clip). Unit-tested in `AnimationMerger_test.cpp` (swing angle, symmetry, idempotence, non-arm invariance). Sentry breadcrumb `ai.assist.text_to_motion` (`arm_space`). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. - **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. diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index ab4e7dd5..0f7eca6e 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -7953,6 +7953,10 @@ Rectangle { spacing: 4 property var entityGroups: PropertiesPanelController.animationData() + // #854: the clip the arm-space slider targets — set when Generate + // runs, cleared on selection change so the slider only shows for a + // freshly generated clip. + property string lastGeneratedAnim: "" function refreshAnimData() { entityGroups = PropertiesPanelController.animationData() @@ -7960,7 +7964,7 @@ Rectangle { Connections { target: PropertiesPanelController - function onSelectionChanged() { refreshAnimData() } + function onSelectionChanged() { lastGeneratedAnim = ""; refreshAnimData() } function onAnimationStateChanged() { refreshAnimData() } } @@ -8008,8 +8012,10 @@ Rectangle { ? "Generating (experimental model)…" : "Generating… (first use downloads the motion library)" genStatus.isError = false - AnimationControlController.generateMotion(genPromptIn.text, 0.0, - useModelChk.checked) + var gr = AnimationControlController.generateMotion( + genPromptIn.text, 0.0, useModelChk.checked, + armSpaceSlider.value) + if (gr && gr.animation) lastGeneratedAnim = gr.animation genBtnBusy = false // generateMotion adds an AnimationState synchronously, but // it lives on AnimationControlController — the Inspector @@ -8046,6 +8052,44 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter } } + // ── Arm space (#854): Mixamo-style widen/tuck post-process ──────── + // Absolute + idempotent, so the slider maps straight to the applied + // angle. Applies on release to the selected generated_* clip. + Row { + width: parent.width - 16; spacing: 6 + visible: lastGeneratedAnim.indexOf("generated_") === 0 + Text { + text: "Arm space" + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + width: 62 + } + Slider { + id: armSpaceSlider + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 62 - 42 - 12 + from: -30; to: 45; value: 0; stepSize: 1 + // The op is ABSOLUTE + idempotent, so re-rewriting per pixel + // is wasteful; apply on release instead. On release, if this + // clip already exists, restyle it directly; otherwise the + // value rides the next Generate. + onPressedChanged: { + if (!pressed + && lastGeneratedAnim.indexOf("generated_") === 0) { + AnimationControlController.adjustArmSpace( + lastGeneratedAnim, value) + refreshAnimData() + } + } + } + Text { + text: (armSpaceSlider.value > 0 ? "+" : "") + + Math.round(armSpaceSlider.value) + "°" + color: PropertiesPanelController.textColor; font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + width: 42; horizontalAlignment: Text.AlignRight + } + } Text { id: genStatus property bool isError: false diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index c725659c..37ebffd1 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1659,8 +1659,38 @@ QVariantMap AnimationControlController::inbetweenWindow(double t0, double t1, return out; } +bool AnimationControlController::adjustArmSpace(const QString& animName, + double degrees) +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return false; + Ogre::Entity* entity = nullptr; + for (auto* e : mgr->getEntities()) { + if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue; + if (m_selectedEntityName.empty() + || e->getName() == m_selectedEntityName) { entity = e; break; } + } + if (!entity) return false; + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + const std::string an = animName.toStdString(); + if (!skel || !skel->hasAnimation(an)) return false; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), + QStringLiteral("GUI arm_space %1 deg").arg(degrees)); + if (!AnimationMerger::adjustArmSpace(skel.get(), an, + static_cast(degrees))) + return false; + + // Refresh the viewport (the animation state's keyframes changed underneath + // it) exactly as the generate path does. + entity->refreshAvailableAnimationState(); + notifyExternalAnimationEdit(); + return true; +} + QVariantMap AnimationControlController::generateMotion(const QString& prompt, - double duration, bool useModel) + double duration, bool useModel, + double armSpaceDeg) { QVariantMap out; out["ok"] = false; @@ -1759,6 +1789,11 @@ QVariantMap AnimationControlController::generateMotion(const QString& prompt, if (!res.ok) return fail(res.error); out["source"] = clipSource; + // #854: optional Mixamo-style arm-space post-process. + if (std::abs(armSpaceDeg) > 1e-4) + AnimationMerger::adjustArmSpace(skel.get(), animName, + static_cast(armSpaceDeg)); + entity->refreshAvailableAnimationState(); // Make the generated clip the ONLY enabled animation. Ogre AVERAGES all // enabled animation states, so leaving the import's auto-enabled clip (or diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index 7ef1034f..ca143131 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -337,7 +337,15 @@ class AnimationControlController : public QObject /// false = the reliable template-clip retarget. Q_INVOKABLE QVariantMap generateMotion(const QString& prompt, double duration = 0.0, - bool useModel = false); + bool useModel = false, + double armSpaceDeg = 0.0); + + /// #854: Mixamo-style arm-space post-process on an EXISTING animation of + /// the selected entity. Positive `degrees` widens the arms away from the + /// body, negative tucks them in, 0 restores the original. ABSOLUTE + + /// idempotent, so it maps directly to a slider (apply on release). Returns + /// true on success. Refreshes the viewport via notifyExternalAnimationEdit. + Q_INVOKABLE bool adjustArmSpace(const QString& animName, double degrees); /// Whole-animation bake helpers: temporarily suppress the per- /// segment QML refresh emitted by resampleCurveSegment so a diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index 31b745b5..e7802d87 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -1299,6 +1300,123 @@ TargetBindFrame readTargetBindFrame(Ogre::Skeleton* skel, } } // namespace +bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, + const std::string& animName, + float degrees) +{ + if (!skel || !skel->hasAnimation(animName)) + return false; + Ogre::Animation* anim = skel->getAnimation(animName); + if (!anim) + return false; + + // Idempotent absolute application: revert whatever we applied before, + // then apply the new absolute angle. The net delta this call injects is + // (degrees − stored). Ogre::Animation carries no UserObjectBindings, so + // the last-applied angle is tracked in a session-scoped map keyed by + // (skeleton, animation) — which is exactly what slider scrubbing needs; + // export bakes the final keyframes, so cross-session persistence isn't + // required. Zeroing the angle restores the clip bit-near-exactly. + static std::map, float> s_applied; + const std::pair key(skel->getName(), animName); + float stored = 0.0f; + if (auto it = s_applied.find(key); it != s_applied.end()) + stored = it->second; + const float delta = degrees - stored; + if (std::abs(delta) < 1e-4f) + return true; // already at target — nothing to do (still success) + + // Bone → canonical role (same matcher as the retarget). + const int nBones = static_cast(skel->getNumBones()); + std::vector boneToCanon(static_cast(nBones), -1); + for (int i = 0; i < nBones; ++i) + boneToCanon[static_cast(i)] = + MotionInbetween::canonicalIndexForBone(QString::fromStdString( + skel->getBone(static_cast(i))->getName())); + + const TargetBindFrame tb = readTargetBindFrame(skel, boneToCanon); + + // Torso FORWARD axis in world space: the retarget's Ct maps the target's + // raw frame onto canonical axes (X=left, Y=up, Z=forward), so Ct⁻¹·+Z is + // the rig's forward direction in its own world. + const Ogre::Vector3 fwd = + (tb.Ct.Inverse() * Ogre::Vector3::UNIT_Z).normalisedCopy(); + + // Per side: swing about the torso forward axis, sign mirrored between + // sides so a POSITIVE `degrees` swings BOTH arms AWAY from the body + // (widen) and negative tucks them in. About canonical +Z (forward) with + // +Y up / +X left, a NEGATIVE rotation lifts the right arm outward and a + // positive one lifts the left, so the right side takes −ang. Collars get + // a fractional share (a small part of the reach). + const Ogre::Radian ang = Ogre::Radian(Ogre::Degree(delta)); + const float kCollarShare = 0.25f; + const Ogre::Quaternion swingR(-ang, fwd); + const Ogre::Quaternion swingL(ang, fwd); + const Ogre::Quaternion swingRc(ang * (-kCollarShare), fwd); + const Ogre::Quaternion swingLc(ang * kCollarShare, fwd); + + // Distribute the role's swing across duplicate bones (multi-segment + // shoulders) so the chain's total matches the requested angle. + std::vector canonDup( + static_cast(MotionInbetween::canonicalJointCount()), 0); + for (int i = 0; i < nBones; ++i) + if (boneToCanon[static_cast(i)] >= 0) + ++canonDup[static_cast(boneToCanon[static_cast(i)])]; + + // Roles that carry an arm-space swing → the world swing for that role. + auto worldSwingForRole = [&](int c) -> const Ogre::Quaternion* { + switch (c) { + case 7: return &swingR; // rshoulder + case 11: return &swingL; // lshoulder + case 6: return &swingRc; // rcollar (fractional) + case 10: return &swingLc; // lcollar (fractional) + default: return nullptr; + } + }; + + bool touchedAny = false; + for (int i = 0; i < nBones; ++i) { + const int c = boneToCanon[static_cast(i)]; + const Ogre::Quaternion* Sworld = worldSwingForRole(c); + if (!Sworld) + continue; + Ogre::NodeAnimationTrack* trk = nullptr; + if (anim->hasNodeTrack(static_cast(i))) + trk = anim->getNodeTrack(static_cast(i)); + if (!trk || trk->getNumKeyFrames() == 0) + continue; + + // Fractional swing when the role spans several bones. + const int dup = std::max(1, canonDup[static_cast(c)]); + Ogre::Quaternion S = *Sworld; + if (dup > 1) + S = Ogre::Quaternion::Slerp(1.0f / static_cast(dup), + Ogre::Quaternion::IDENTITY, S, + /*shortestPath=*/true); + + // applyToNode composes the keyframe as localApplied = bindLocal · kf, + // and world = W_parent · localApplied = W_shoulder_bind · kf. To add + // a world swing S at this bone (parents untouched), the new keyframe + // is L · kf where L conjugates S into the bone's bind-local frame: + // L = W_bind⁻¹ · S · W_bind + const Ogre::Quaternion Wbind = tb.bindWorld[static_cast(i)]; + const Ogre::Quaternion L = Wbind.Inverse() * S * Wbind; + + const unsigned short nk = trk->getNumKeyFrames(); + for (unsigned short k = 0; k < nk; ++k) { + Ogre::TransformKeyFrame* kf = trk->getNodeKeyFrame(k); + kf->setRotation(L * kf->getRotation()); + } + touchedAny = true; + } + + if (!touchedAny) + return false; // no arm role on this rig + + s_applied[key] = degrees; + return true; +} + AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip( Ogre::Skeleton* skel, const std::string& animName, diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index 8b4f24c1..fd2f2f2c 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -212,6 +212,32 @@ class AnimationMerger { std::vector> restDir; }; + /// Post-process a generated animation to widen (+) or tuck (−) the arm + /// chains, à la Mixamo's "Character Arm-Space" (#854). Rescues arm-into- + /// torso clipping / too-wide arms on rigs whose proportions differ from + /// the source clip, without touching the retarget math. + /// + /// Mechanics: for each bone mapped to a shoulder role (canonical 7 right, + /// 11 left) — plus a fractional share on the collars (6/10) — a swing of + /// `degrees` about the torso's FORWARD axis (from the target bind frame, + /// mirrored per side: + swings both arms away from the body) is injected + /// into WORLD space and folded back into the bone's local keyframe deltas. + /// Elbows/hands inherit through the hierarchy (keyframes are parent- + /// relative), so only the shoulders/collars are rewritten. The full angle + /// is split across duplicate role bones so multi-segment shoulders don't + /// over-rotate. + /// + /// IDEMPOTENT: the net applied angle is stored on the animation via + /// UserObjectBindings ("qtme.armspace"); re-applying first reverts the + /// stored angle, so `adjustArmSpace(20)` then `adjustArmSpace(10)` == + /// `adjustArmSpace(10)` from the original, and `adjustArmSpace(0)` + /// restores the clip bit-near-exactly. `degrees` is the ABSOLUTE target. + /// Returns false (no-op) if the animation is missing or no arm role + /// resolves on the rig. + static bool adjustArmSpace(Ogre::Skeleton* skel, + const std::string& animName, + float degrees); + /// Sample every (or one) skeletal animation of `entity` at `fps` and /// express each canonical joint's world orientation per frame. Bone→role /// mapping is MotionInbetween::canonicalIndexForBone — the same matcher diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index f9abfd95..b0490645 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -963,3 +963,131 @@ TEST_F(AnimationMergerTest, ExtractCanonicalClipsSamplesWorldFrame) sceneMgr->destroyEntity(ent); } + +// ── #854: arm-space post-process ──────────────────────────────────────────── + +namespace { +// Minimal humanoid with a torso + both arm chains, T-posed (arms out along +// ±X), one identity-keyframe animation on every bone so adjustArmSpace has +// tracks to rewrite. Returns the skeleton (bind pose set) + anim name. +Ogre::SkeletonPtr makeArmRig(const Ogre::String& name) +{ + auto skel = Ogre::SkeletonManager::getSingleton().create( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + unsigned short h = 0; + auto bone = [&](const char* n, const Ogre::Vector3& p, Ogre::Bone* parent) { + auto* b = skel->createBone(n, h++); + b->setPosition(p); + if (parent) parent->addChild(b); + return b; + }; + // spine up +Y, legs for the Ct frame, arms out along ±X (T-pose) + auto* hips = bone("Hips", {0, 1.0f, 0}, nullptr); + auto* chest = bone("Spine", {0, 0.3f, 0}, hips); + auto* head = bone("Head", {0, 0.4f, 0}, chest); + bone("LeftUpLeg", {0.15f, -0.1f, 0}, hips); + bone("RightUpLeg", {-0.15f, -0.1f, 0}, hips); + // right arm out along −X (rig's right), left along +X + auto* rsh = bone("RightArm", {-0.2f, 0.1f, 0}, chest); + bone("RightForeArm", {-0.3f, 0, 0}, rsh); + auto* lsh = bone("LeftArm", {0.2f, 0.1f, 0}, chest); + bone("LeftForeArm", {0.3f, 0, 0}, lsh); + skel->setBindingPose(); + // Identity animation: one keyframe per bone at t=0 and t=1. + auto* anim = skel->createAnimation("clip", 1.0f); + for (unsigned short i = 0; i < skel->getNumBones(); ++i) { + auto* trk = anim->createNodeTrack(i, skel->getBone(i)); + trk->createNodeKeyFrame(0.0f); + trk->createNodeKeyFrame(1.0f); + } + return skel; +} + +// World direction of an arm bone (down its length toward the child) at t. +Ogre::Vector3 armWorldDir(Ogre::SkeletonPtr skel, const char* boneName, + const char* childName, float t) +{ + skel->getAnimation("clip")->apply(skel.get(), t); + skel->_updateTransforms(); + const Ogre::Vector3 a = skel->getBone(boneName)->_getDerivedPosition(); + const Ogre::Vector3 b = skel->getBone(childName)->_getDerivedPosition(); + return (b - a).normalisedCopy(); +} +} // namespace + +TEST(AnimationMergerArmSpaceTest, WidensAndTucksArms) +{ + auto skel = makeArmRig("armspace_widen"); + const Ogre::Vector3 rBase = + armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); + + // +30° must widen: the right arm's downward (−Y) component increases as it + // swings out/down in the frontal plane; the sideways spread grows. + ASSERT_TRUE(AnimationMerger::adjustArmSpace(skel.get(), "clip", 30.0f)); + const Ogre::Vector3 rWide = + armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); + // The arm actually moved by ~30° about the forward (Z) axis. + const float ang = std::acos(std::min(1.0f, rBase.dotProduct(rWide))) + * 180.0f / static_cast(M_PI); + EXPECT_NEAR(ang, 30.0f, 4.0f); + + // Symmetry: the left arm swings by the same angle, mirrored. + auto skelL = makeArmRig("armspace_widenL"); + const Ogre::Vector3 lBase = + armWorldDir(skelL, "LeftArm", "LeftForeArm", 0.5f); + AnimationMerger::adjustArmSpace(skelL.get(), "clip", 30.0f); + const Ogre::Vector3 lWide = + armWorldDir(skelL, "LeftArm", "LeftForeArm", 0.5f); + const float angL = std::acos(std::min(1.0f, lBase.dotProduct(lWide))) + * 180.0f / static_cast(M_PI); + EXPECT_NEAR(angL, 30.0f, 4.0f); +} + +TEST(AnimationMergerArmSpaceTest, IdempotentAbsoluteApplication) +{ + auto skel = makeArmRig("armspace_idem"); + const Ogre::Vector3 base = + armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); + + // Apply 20 then 10 → must equal applying 10 from the original (absolute). + AnimationMerger::adjustArmSpace(skel.get(), "clip", 20.0f); + AnimationMerger::adjustArmSpace(skel.get(), "clip", 10.0f); + const Ogre::Vector3 at10 = + armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); + + auto ref = makeArmRig("armspace_idem_ref"); + AnimationMerger::adjustArmSpace(ref.get(), "clip", 10.0f); + const Ogre::Vector3 ref10 = + armWorldDir(ref, "RightArm", "RightForeArm", 0.5f); + EXPECT_GT(at10.dotProduct(ref10), 0.9999f); // same direction + + // Back to 0 restores the original bind-referenced pose bit-near-exactly. + AnimationMerger::adjustArmSpace(skel.get(), "clip", 0.0f); + const Ogre::Vector3 restored = + armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); + EXPECT_GT(base.dotProduct(restored), 0.99999f); +} + +TEST(AnimationMergerArmSpaceTest, LeavesNonArmBonesUntouched) +{ + auto skel = makeArmRig("armspace_legs"); + const Ogre::Vector3 legBase = + armWorldDir(skel, "Hips", "LeftUpLeg", 0.5f); + const Ogre::Vector3 spineBase = + armWorldDir(skel, "Hips", "Spine", 0.5f); + + AnimationMerger::adjustArmSpace(skel.get(), "clip", 40.0f); + const Ogre::Vector3 legAfter = + armWorldDir(skel, "Hips", "LeftUpLeg", 0.5f); + const Ogre::Vector3 spineAfter = + armWorldDir(skel, "Hips", "Spine", 0.5f); + EXPECT_GT(legBase.dotProduct(legAfter), 0.99999f); + EXPECT_GT(spineBase.dotProduct(spineAfter), 0.99999f); +} + +TEST(AnimationMergerArmSpaceTest, NoOpWhenAnimationMissing) +{ + auto skel = makeArmRig("armspace_missing"); + EXPECT_FALSE(AnimationMerger::adjustArmSpace(skel.get(), "nope", 20.0f)); + EXPECT_FALSE(AnimationMerger::adjustArmSpace(nullptr, "clip", 20.0f)); +} diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 5d2b4b32..e83e9dd4 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -2002,7 +2002,8 @@ int CLIPipeline::cmdFix(int argc, char* argv[]) int CLIPipeline::cmdAnimGenerate(const QString& filePath, const QString& prompt, float duration, const QString& outputPath, - bool jsonOutput, bool useModel) + bool jsonOutput, bool useModel, + float armSpaceDeg) { // #411 text-to-motion (template-clip MVP): match the prompt to a permissive // CMU motion clip from the downloadable library, retarget it onto the mesh's @@ -2120,6 +2121,18 @@ int CLIPipeline::cmdAnimGenerate(const QString& filePath, const QString& prompt, } err() << "(source: " << clipSource << ")" << Qt::endl; + // #854: optional Mixamo-style arm-space post-process before export. + if (std::abs(armSpaceDeg) > 1e-4f) { + if (AnimationMerger::adjustArmSpace(skel.get(), animName, armSpaceDeg)) { + SentryReporter::addBreadcrumb( + QStringLiteral("ai.assist.text_to_motion"), + QStringLiteral("arm_space %1 deg").arg(armSpaceDeg)); + err() << "(arm-space: " << armSpaceDeg << " deg)" << Qt::endl; + } else + err() << "Note: arm-space adjustment skipped (no arm roles)." + << Qt::endl; + } + auto* node = entity->getParentSceneNode(); const QString fmt = formatForExtension(outputPath); if (MeshImporterExporter::exporter(node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { @@ -2170,6 +2183,8 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) QString generatePrompt; // --generate "" float generateDuration = 0.0f; // --duration N (seconds; 0 = clip's native length) bool generateUseModel = false; // --model → experimental trained t2m model (template fallback) + float armSpaceDeg = 0.0f; // #854: Mixamo-style arm-space swing (degrees) + bool armSpaceSet = false; // --arm-space given (standalone post-adjust) bool jsonOutput = false; int resampleCount = 0; int decimateStep = 0; @@ -2257,6 +2272,11 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) generateDuration = QString(argv[++i]).toFloat(); continue; } + if (arg == "--arm-space" && i + 1 < argc) { + armSpaceDeg = QString(argv[++i]).toFloat(); + armSpaceSet = true; + continue; + } if (arg == "--simplify") { simplifyMode = true; continue; } if (arg == "--analyze") { analyzeMode = true; continue; } if (arg == "--preset" && i + 1 < argc) { @@ -2311,7 +2331,50 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) } return cmdAnimGenerate(filePath, generatePrompt, generateDuration, outputPath.isEmpty() ? filePath : outputPath, jsonOutput, - generateUseModel); + generateUseModel, armSpaceDeg); + } + + // #854 standalone: post-adjust the arm space of an EXISTING animation + // (no --generate). `qtmesh anim --arm-space --animation -o out`. + if (armSpaceSet) { + if (animationFilter.isEmpty()) { + err() << "Error: --arm-space (standalone) requires --animation ." + << Qt::endl; + return 2; + } + if (!initOgreHeadless()) return 1; + MeshImporterExporter::importer({QFileInfo(filePath).absoluteFilePath()}); + Ogre::Entity* entity = nullptr; + for (auto* e : Manager::getSingleton()->getEntities()) + if (e && e->getMovableType() == "Entity" && e->hasSkeleton()) { entity = e; break; } + if (!entity) { + err() << "Error: " << filePath << " has no skinned mesh." << Qt::endl; + return 1; + } + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); + const std::string an = animationFilter.toStdString(); + if (!skel->hasAnimation(an)) { + err() << "Error: animation '" << animationFilter << "' not found." + << Qt::endl; + return 1; + } + if (!AnimationMerger::adjustArmSpace(skel.get(), an, armSpaceDeg)) { + err() << "Error: arm-space adjustment failed (no arm roles on this rig)." + << Qt::endl; + return 1; + } + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), + QStringLiteral("arm_space %1 deg").arg(armSpaceDeg)); + const QString out = outputPath.isEmpty() ? filePath : outputPath; + auto* node = entity->getParentSceneNode(); + if (MeshImporterExporter::exporter(node, QFileInfo(out).absoluteFilePath(), + formatForExtension(out)) != 0) { + err() << "Error: export failed." << Qt::endl; return 1; + } + cliWrite(QString("Adjusted arm space of '%1' to %2 deg → %3\n") + .arg(animationFilter).arg(armSpaceDeg) + .arg(QFileInfo(out).fileName())); + return 0; } if (!listMode && !renameMode && !mergeMode && !resampleMode && !decimateMode diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 60839943..775d501f 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -92,7 +92,8 @@ class CLIPipeline { // #411 text-to-motion (template-clip MVP): prompt → library clip → retarget. static int cmdAnimGenerate(const QString& filePath, const QString& prompt, float duration, const QString& outputPath, - bool jsonOutput, bool useModel = false); + bool jsonOutput, bool useModel = false, + float armSpaceDeg = 0.0f); static int cmdValidate(int argc, char* argv[]); static int cmdLod(int argc, char* argv[]); static int cmdPose(int argc, char* argv[]); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 3ac1b533..61f38faf 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -649,6 +649,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("bake_animation_fps"), &MCPServer::toolBakeAnimationFps}, {QStringLiteral("motion_in_between"), &MCPServer::toolMotionInBetween}, {QStringLiteral("generate_motion"), &MCPServer::toolGenerateMotion}, + {QStringLiteral("adjust_arm_space"), &MCPServer::toolAdjustArmSpace}, {QStringLiteral("segment_mesh"), &MCPServer::toolSegmentMesh}, {QStringLiteral("generate_mesh_from_image"), &MCPServer::toolGenerateMeshFromImage}, {QStringLiteral("save_scene"), &MCPServer::toolSaveScene}, @@ -4118,6 +4119,12 @@ QJsonObject MCPServer::toolGenerateMotion(const QJsonObject &args) clipDirs); if (!r.ok) return makeErrorResult(QString("Error: %1").arg(r.error)); + // #854: optional Mixamo-style arm-space post-process. + const double armSpace = args.value("arm_space").toDouble(0.0); + if (std::abs(armSpace) > 1e-4) + AnimationMerger::adjustArmSpace(skel.get(), animName, + static_cast(armSpace)); + entity->refreshAvailableAnimationState(); // Exclusively enable the generated clip — enabled states BLEND in // Ogre, and mixing with the import's auto-enabled animation renders @@ -4163,6 +4170,73 @@ QJsonObject MCPServer::toolGenerateMotion(const QJsonObject &args) } } +QJsonObject MCPServer::toolAdjustArmSpace(const QJsonObject &args) +{ + try { + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), + QStringLiteral("MCP adjust_arm_space")); + + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return makeErrorResult("Error: Manager not available"); + + const QString animName = args.value("animation_name").toString(); + if (animName.isEmpty()) + return makeErrorResult("Error: animation_name is required."); + const double degrees = args.value("arm_space").toDouble( + args.value("degrees").toDouble(0.0)); + + const QString entityName = args.value("entity_name").toString(); + Ogre::Entity* entity = nullptr; + for (auto* ent : mgr->getEntities()) { + if (!ent || ent->getMovableType() != "Entity" || !ent->hasSkeleton()) + continue; + if (entityName.isEmpty() + || QString::fromStdString(ent->getName()) == entityName) { + entity = ent; break; + } + } + if (!entity) + return makeErrorResult("Error: no matching rigged entity."); + + Ogre::SkeletonInstance* skel = entity->getSkeleton(); + const std::string an = animName.toStdString(); + if (!skel || !skel->hasAnimation(an)) + return makeErrorResult( + QString("Error: animation '%1' not found on entity.").arg(animName)); + + if (!AnimationMerger::adjustArmSpace(skel, an, + static_cast(degrees))) + return makeErrorResult( + "Error: arm-space adjustment failed (no arm roles on this rig)."); + + if (auto* acc = AnimationControlController::instance()) + acc->notifyExternalAnimationEdit(); + + const QString outPath = args.value("output_path").toString(); + if (!outPath.isEmpty()) { + auto* node = entity->getParentSceneNode(); + if (MeshImporterExporter::exporter( + node, outPath, CLIPipeline::formatForExtension(outPath)) != 0) + return makeErrorResult( + QString("Error: adjusted arm space but export to %1 failed") + .arg(outPath)); + } + + QJsonObject content; + content["ok"] = true; + content["animation"] = animName; + content["arm_space"] = degrees; + content["entity"] = QString::fromStdString(entity->getName()); + if (!outPath.isEmpty()) content["exported"] = outPath; + return makeSuccessResult( + QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); + } catch (Ogre::Exception& e) { + return makeErrorResult(QString("Error: Ogre exception — %1").arg(e.getFullDescription().c_str())); + } catch (std::exception& e) { + return makeErrorResult(QString("Error: %1").arg(e.what())); + } +} + QJsonObject MCPServer::toolSegmentMesh(const QJsonObject &args) { try { @@ -7960,6 +8034,7 @@ QJsonArray MCPServer::buildToolsList() props["duration"] = QJsonObject{{"type", "number"}, {"description", "Optional clip length in seconds (retimes the template). Default: the clip's native length."}}; props["output_path"] = QJsonObject{{"type", "string"}, {"description", "Optional path to re-export the mesh with the new animation (e.g. /tmp/out.glb). If omitted, the animation is applied in-session only."}}; props["model"] = QJsonObject{{"type", "boolean"}, {"description", "EXPERIMENTAL: use the trained from-scratch text-to-motion ONNX model instead of the template clip. Falls back to the template library automatically if the model is unavailable or the action isn't in its vocabulary. Default false (template). Quality is action-dependent (locomotion better than gestures)."}}; + props["arm_space"] = QJsonObject{{"type", "number"}, {"description", "Optional Mixamo-style arm-space post-process in degrees (#854): positive widens the arms away from the body, negative tucks them in. Default 0. Rescues arm-into-torso clipping on rigs whose proportions differ from the clip."}}; appendTool( "generate_motion", "AI text-to-motion (#411, experimental): generate a skeletal animation from a text prompt and " @@ -7973,6 +8048,25 @@ QJsonArray MCPServer::buildToolsList() ); } + // adjust_arm_space + { + QJsonObject props; + props["animation_name"] = QJsonObject{{"type", "string"}, {"description", "Name of the animation to adjust, e.g. \"generated_walk\"."}}; + props["arm_space"] = QJsonObject{{"type", "number"}, {"description", "Absolute arm-space angle in degrees: positive widens the arms away from the body, negative tucks them in, 0 restores the original. Idempotent — re-applying reverts the previous value first."}}; + props["entity_name"] = QJsonObject{{"type", "string"}, {"description", "Name of the rigged entity. If omitted, uses the first skinned entity."}}; + props["output_path"] = QJsonObject{{"type", "string"}, {"description", "Optional path to re-export the mesh with the adjusted animation. If omitted, applied in-session only."}}; + appendTool( + "adjust_arm_space", + "Mixamo-style arm-space post-process (#854): swing the arm chains of an existing animation outward " + "(widen) or inward (tuck) to rescue arm-into-torso clipping on rigs whose proportions differ from " + "the source clip. Only the shoulders/collars are rewritten; elbows/hands follow through the " + "hierarchy, and legs/spine are untouched. The angle is ABSOLUTE and idempotent (re-applying reverts " + "the prior value), so it maps directly to a UI slider.", + props, + QJsonArray{"animation_name", "arm_space"} + ); + } + // segment_mesh { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index a500921e..82c7633a 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -209,6 +209,7 @@ private slots: QJsonObject toolBakeAnimationFps(const QJsonObject &args); QJsonObject toolMotionInBetween(const QJsonObject &args); QJsonObject toolGenerateMotion(const QJsonObject &args); // #411 text-to-motion + QJsonObject toolAdjustArmSpace(const QJsonObject &args); // #854 arm-space QJsonObject toolSegmentMesh(const QJsonObject &args); QJsonObject toolGenerateMeshFromImage(const QJsonObject &args); // #764 image-to-3D QJsonObject toolSaveScene(const QJsonObject &args); From 2bb663e3aaddb3ec01f8d7a724d5fbd765088421 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 11 Jul 2026 23:11:44 -0400 Subject: [PATCH 02/16] fix(anim): arm-space test crash + live/paused GUI, any-clip targeting (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the arm-space unit suite SIGSEGV-ing: the tests drove a bare Ogre::Skeleton (apply()/_updateTransforms() on an unloaded skeleton crashes). Rebuilt them as TEST_F on the fixture, driving a real SkeletonInstance from a loaded Entity (createInMemoryMesh) — the same object the runtime uses. Coverage unchanged: widen/tuck angle, per-side symmetry, idempotence + absolute (via currentArmSpace), non-arm-bone invariance, missing-animation no-op. GUI improvements from user testing: - The slider value no longer bakes into generation — Generate always produces a CLEAN clip; arm-space is purely a post-adjustment. A leftover slider value was silently skewing every newly generated clip. - The slider works on ANY animation, not just generated ones: each animation row has a "↔" button that targets the slider at that clip (seeded with the clip's real applied angle via the new AnimationMerger::currentArmSpace / controller getter). The edit sticks to its clip (exports widened); switching/​reselecting just detaches. - Live update while dragging (absolute+idempotent, so re-applying per degree never accumulates), and it now re-poses the mesh even when the clip is PAUSED — _notifyDirty + re-stamp the state's time so a stopped clip refreshes immediately instead of freezing on the pre-edit pose. - Generate prompt field: force-focus on press (mouse.accepted=false so selectByMouse still positions the caret) — after using the slider/arm buttons the field wouldn't re-focus. Core adjustArmSpace unchanged; the applied-angle map moved to file scope so currentArmSpace() can read it. Co-Authored-By: Claude Opus 4.8 (1M context) --- qml/PropertiesPanel.qml | 154 +++++++++++++++++--- src/AnimationControlController.cpp | 44 +++++- src/AnimationControlController.h | 10 +- src/AnimationMerger.cpp | 30 ++-- src/AnimationMerger.h | 6 + src/AnimationMerger_test.cpp | 217 ++++++++++++++--------------- 6 files changed, 319 insertions(+), 142 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 0f7eca6e..dd132338 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -7953,18 +7953,45 @@ Rectangle { spacing: 4 property var entityGroups: PropertiesPanelController.animationData() - // #854: the clip the arm-space slider targets — set when Generate - // runs, cleared on selection change so the slider only shows for a - // freshly generated clip. + // #854: the clip the arm-space slider targets. Defaults to the last + // generated clip, but the per-row arm button can point it at ANY + // animation. Cleared on selection change. property string lastGeneratedAnim: "" + property string armSpaceAnim: "" + property string armSpaceEntity: "" function refreshAnimData() { entityGroups = PropertiesPanelController.animationData() } + // #854: point the arm-space slider at a clip. The adjustment + // STICKS to that clip (so it exports widened) and is independent + // per clip. Generation always produces a CLEAN clip (slider value + // never bakes into it), and the slider is seeded with the target + // clip's ACTUAL current angle — so switching clips shows the truth + // and a fresh generate shows 0. Closing the panel / reselecting + // just detaches the slider (no revert; the clip keeps its edit). + function setArmSpaceTarget(animName, entity) { + armSpaceAnim = animName + armSpaceEntity = entity + var cur = AnimationControlController.currentArmSpace(animName, entity) + armSpaceSlider.value = cur + armSpaceSlider.lastApplied = cur + } + function detachArmSpace() { + armSpaceAnim = "" + armSpaceEntity = "" + armSpaceSlider.value = 0 + armSpaceSlider.lastApplied = 0 + } + Connections { target: PropertiesPanelController - function onSelectionChanged() { lastGeneratedAnim = ""; refreshAnimData() } + function onSelectionChanged() { + lastGeneratedAnim = "" + detachArmSpace() + refreshAnimData() + } function onAnimationStateChanged() { refreshAnimData() } } @@ -7988,6 +8015,27 @@ Rectangle { verticalAlignment: TextInput.AlignVCenter color: PropertiesPanelController.textColor; font.pixelSize: 11 clip: true + // selectByMouse lets a click position the caret AND take + // keyboard focus — without it a bare TextInput embedded + // in a QQuickWidget stays unfocused on click (the other + // working fields in this panel all set it). activeFocus- + // OnPress is the default but stated for clarity. + selectByMouse: true + activeFocusOnPress: true + // Belt-and-suspenders focus grab: after using the + // arm-space slider / arm buttons (which take focus and + // can leave the QQuickWidget's focus on the viewport), + // a plain click here didn't always re-focus the field. + // Force it on press and let the event through (accepted + // = false) so selectByMouse still positions the caret. + MouseArea { + anchors.fill: parent + cursorShape: Qt.IBeamCursor + onPressed: function(mouse) { + genPromptIn.forceActiveFocus() + mouse.accepted = false + } + } Text { anchors.verticalCenter: parent.verticalCenter visible: !genPromptIn.text && !genPromptIn.activeFocus @@ -8012,10 +8060,19 @@ Rectangle { ? "Generating (experimental model)…" : "Generating… (first use downloads the motion library)" genStatus.isError = false + // Generate a CLEAN clip — arm-space is always an + // explicit post-adjustment starting from 0, never baked + // into generation (a leftover slider value silently + // skewing every new clip is exactly the bug this fixes). var gr = AnimationControlController.generateMotion( genPromptIn.text, 0.0, useModelChk.checked, - armSpaceSlider.value) - if (gr && gr.animation) lastGeneratedAnim = gr.animation + 0.0) + if (gr && gr.animation) { + lastGeneratedAnim = gr.animation + // Point the slider at the fresh clip at a neutral 0 + // (reverts any prior target — see setArmSpaceTarget). + setArmSpaceTarget(gr.animation, gr.entity || "") + } genBtnBusy = false // generateMotion adds an AnimationState synchronously, but // it lives on AnimationControlController — the Inspector @@ -8053,34 +8110,42 @@ Rectangle { } } // ── Arm space (#854): Mixamo-style widen/tuck post-process ──────── - // Absolute + idempotent, so the slider maps straight to the applied - // angle. Applies on release to the selected generated_* clip. + // Targets `armSpaceAnim` — the last generated clip by default, or + // any animation the user picks via the per-row arm button below. + // The op is ABSOLUTE + idempotent, so dragging can re-apply live at + // each value (no accumulation) and the slider maps straight to the + // stored angle. Row { width: parent.width - 16; spacing: 6 - visible: lastGeneratedAnim.indexOf("generated_") === 0 + visible: armSpaceAnim.length > 0 Text { text: "Arm space" color: PropertiesPanelController.textColor; font.pixelSize: 10 anchors.verticalCenter: parent.verticalCenter width: 62 + elide: Text.ElideRight } Slider { id: armSpaceSlider anchors.verticalCenter: parent.verticalCenter width: parent.width - 62 - 42 - 12 from: -30; to: 45; value: 0; stepSize: 1 - // The op is ABSOLUTE + idempotent, so re-rewriting per pixel - // is wasteful; apply on release instead. On release, if this - // clip already exists, restyle it directly; otherwise the - // value rides the next Generate. - onPressedChanged: { - if (!pressed - && lastGeneratedAnim.indexOf("generated_") === 0) { + // Live update: adjustArmSpace is absolute+idempotent, so + // firing it per value while dragging just re-applies (never + // accumulates). Throttle to whole degrees via stepSize so we + // rewrite keyframes at most ~75 times across the range. + property real lastApplied: 0 + onValueChanged: { + if (armSpaceAnim.length > 0 + && Math.round(value) !== Math.round(lastApplied)) { + lastApplied = value AnimationControlController.adjustArmSpace( - lastGeneratedAnim, value) - refreshAnimData() + armSpaceAnim, value, armSpaceEntity) } } + // Refresh the Inspector list only on release (the viewport + // itself updates live inside adjustArmSpace). + onPressedChanged: { if (!pressed) refreshAnimData() } } Text { text: (armSpaceSlider.value > 0 ? "+" : "") @@ -8090,6 +8155,16 @@ Rectangle { width: 42; horizontalAlignment: Text.AlignRight } } + // Label showing which clip the slider affects (only when it's not + // the obvious just-generated one). + Text { + visible: armSpaceAnim.length > 0 + width: parent.width - 16; wrapMode: Text.Wrap + text: "↑ adjusting \"" + armSpaceAnim + "\" (0 = no change). The " + + "edit stays on this clip; click ↔ again to detach." + color: PropertiesPanelController.textColor; opacity: 0.5 + font.pixelSize: 9 + } Text { id: genStatus property bool isError: false @@ -8598,6 +8673,49 @@ Rectangle { } } + // Arm space (#854) — point the arm-space + // slider (above) at THIS clip. Works on any + // skeletal animation, not just generated + // ones. Highlights when it's the active target. + Rectangle { + id: armBtn + visible: grp.hasSkeleton + width: 22; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + property bool active: armSpaceAnim === modelData.name + && armSpaceEntity === grp.entity + color: active ? PropertiesPanelController.highlightColor + : armMouse.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2) + : armMouse.containsMouse ? Qt.lighter(PropertiesPanelController.headerColor, 1.2) + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor; border.width: 1 + Text { + anchors.centerIn: parent + text: "↔" // ↔ widen/tuck + color: armBtn.active ? "white" + : PropertiesPanelController.textColor + font.pixelSize: 12 + } + ToolTip.visible: armMouse.containsMouse + ToolTip.delay: 600 + ToolTip.text: "Arm space — retarget the widen/tuck slider to this animation" + MouseArea { + id: armMouse; anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + // Toggle: detach if it's already + // the target (the clip keeps its + // edit), else target this clip + // (seeded with its real angle). + if (armBtn.active) + detachArmSpace() + else + setArmSpaceTarget( + modelData.name, grp.entity) + } + } + } + // Bake — resample / reduce every bone track in this // animation. Mirrors the curve editor's per-bone Bake // dropdown but applies to the whole animation in one diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 37ebffd1..d37cd05c 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1660,15 +1660,17 @@ QVariantMap AnimationControlController::inbetweenWindow(double t0, double t1, } bool AnimationControlController::adjustArmSpace(const QString& animName, - double degrees) + double degrees, + const QString& entityName) { Manager* mgr = Manager::getSingletonPtr(); if (!mgr) return false; + const std::string want = entityName.isEmpty() + ? m_selectedEntityName : entityName.toStdString(); Ogre::Entity* entity = nullptr; for (auto* e : mgr->getEntities()) { if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue; - if (m_selectedEntityName.empty() - || e->getName() == m_selectedEntityName) { entity = e; break; } + if (want.empty() || e->getName() == want) { entity = e; break; } } if (!entity) return false; Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); @@ -1681,13 +1683,44 @@ bool AnimationControlController::adjustArmSpace(const QString& animName, static_cast(degrees))) return false; - // Refresh the viewport (the animation state's keyframes changed underneath - // it) exactly as the generate path does. + // Re-pose the mesh NOW, even when the clip is paused. Rewriting keyframes + // doesn't move the bones until the animation state re-applies; when + // playback is stopped nothing ticks it, so the viewport would stay frozen + // on the pre-edit pose until the user hits play. Mark the state dirty and + // re-stamp its current time to force an immediate re-evaluation (the same + // idiom as notifyOgreUpdate, but targeting THIS entity + clip so it works + // for any animation, not just the selected one). entity->refreshAvailableAnimationState(); + if (auto* states = entity->getAllAnimationStates()) { + states->_notifyDirty(); + if (states->hasAnimationState(an)) { + auto* st = states->getAnimationState(an); + if (st->getEnabled()) + st->setTimePosition(st->getTimePosition()); + } + } notifyExternalAnimationEdit(); return true; } +double AnimationControlController::currentArmSpace(const QString& animName, + const QString& entityName) +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return 0.0; + const std::string want = entityName.isEmpty() + ? m_selectedEntityName : entityName.toStdString(); + for (auto* e : mgr->getEntities()) { + if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue; + if (want.empty() || e->getName() == want) { + Ogre::SkeletonPtr skel = e->getMesh()->getSkeleton(); + return skel ? AnimationMerger::currentArmSpace( + skel.get(), animName.toStdString()) : 0.0; + } + } + return 0.0; +} + QVariantMap AnimationControlController::generateMotion(const QString& prompt, double duration, bool useModel, double armSpaceDeg) @@ -1829,6 +1862,7 @@ QVariantMap AnimationControlController::generateMotion(const QString& prompt, out["action"] = action; out["source"] = clipSource; out["animation"] = QString::fromStdString(animName); + out["entity"] = QString::fromStdString(entity->getName()); out["frames"] = res.frames; out["length"] = res.length; out["tracksWritten"] = res.tracksWritten; diff --git a/src/AnimationControlController.h b/src/AnimationControlController.h index ca143131..8e4d0fd2 100644 --- a/src/AnimationControlController.h +++ b/src/AnimationControlController.h @@ -345,7 +345,15 @@ class AnimationControlController : public QObject /// body, negative tucks them in, 0 restores the original. ABSOLUTE + /// idempotent, so it maps directly to a slider (apply on release). Returns /// true on success. Refreshes the viewport via notifyExternalAnimationEdit. - Q_INVOKABLE bool adjustArmSpace(const QString& animName, double degrees); + /// `entityName` empty → the selected entity, else the first skinned mesh; + /// pass it to target a clip on a specific entity (the per-row GUI control). + Q_INVOKABLE bool adjustArmSpace(const QString& animName, double degrees, + const QString& entityName = QString()); + + /// The arm-space angle currently applied to `animName` (0 if none) — so + /// the GUI slider can show the clip's real value when (re)targeting it. + Q_INVOKABLE double currentArmSpace(const QString& animName, + const QString& entityName = QString()); /// Whole-animation bake helpers: temporarily suppress the per- /// segment QML refresh emitted by resampleCurveSegment so a diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index e7802d87..ef3f4942 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -21,6 +21,12 @@ // Populated by AnimationMerger::registerSkeletonUpAxis() at import time. static QMap s_skeletonUpAxis; +// #854: last-applied arm-space angle per (skeleton, animation), session +// scoped. adjustArmSpace() reads it to make each apply absolute+idempotent; +// currentArmSpace() exposes it so a UI can seed the slider with the clip's +// real value (never persisted — export bakes the keyframes). +static std::map, float> g_armSpaceApplied; + void AnimationMerger::registerSkeletonUpAxis(const std::string& name, int upAxis) { s_skeletonUpAxis[QString::fromStdString(name)] = upAxis; } @@ -1300,6 +1306,14 @@ TargetBindFrame readTargetBindFrame(Ogre::Skeleton* skel, } } // namespace +float AnimationMerger::currentArmSpace(Ogre::Skeleton* skel, + const std::string& animName) +{ + if (!skel) return 0.0f; + const auto it = g_armSpaceApplied.find({skel->getName(), animName}); + return it != g_armSpaceApplied.end() ? it->second : 0.0f; +} + bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, const std::string& animName, float degrees) @@ -1312,15 +1326,15 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, // Idempotent absolute application: revert whatever we applied before, // then apply the new absolute angle. The net delta this call injects is - // (degrees − stored). Ogre::Animation carries no UserObjectBindings, so - // the last-applied angle is tracked in a session-scoped map keyed by - // (skeleton, animation) — which is exactly what slider scrubbing needs; - // export bakes the final keyframes, so cross-session persistence isn't - // required. Zeroing the angle restores the clip bit-near-exactly. - static std::map, float> s_applied; + // (degrees − stored). The last-applied angle per (skeleton, animation) is + // tracked in the file-scoped g_armSpaceApplied map (Ogre::Animation has no + // UserObjectBindings) — which is exactly what slider scrubbing needs, and + // currentArmSpace() exposes it so a UI can seed the slider with the clip's + // real value. Export bakes the final keyframes, so cross-session + // persistence isn't required. Zeroing restores the clip bit-near-exactly. const std::pair key(skel->getName(), animName); float stored = 0.0f; - if (auto it = s_applied.find(key); it != s_applied.end()) + if (auto it = g_armSpaceApplied.find(key); it != g_armSpaceApplied.end()) stored = it->second; const float delta = degrees - stored; if (std::abs(delta) < 1e-4f) @@ -1413,7 +1427,7 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, if (!touchedAny) return false; // no arm role on this rig - s_applied[key] = degrees; + g_armSpaceApplied[key] = degrees; return true; } diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index fd2f2f2c..b6b17517 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -238,6 +238,12 @@ class AnimationMerger { const std::string& animName, float degrees); + /// The arm-space angle currently applied to `animName` (0 if none) — the + /// value adjustArmSpace last stored this session. Lets a UI seed its + /// slider with the clip's real state instead of assuming 0. + static float currentArmSpace(Ogre::Skeleton* skel, + const std::string& animName); + /// Sample every (or one) skeletal animation of `entity` at `fps` and /// express each canonical joint's world orientation per frame. Bone→role /// mapping is MotionInbetween::canonicalIndexForBone — the same matcher diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index b0490645..d43f8fc9 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -98,6 +98,58 @@ class AnimationMergerTest : public ::testing::Test { return skel; } + + // #854 arm-space helpers. Build a T-posed humanoid (torso + both arm + // chains + legs for the Ct frame), wrap it in a mesh + Entity, and return + // the Entity so tests drive the SkeletonInstance (safe to apply()) — a + // bare Skeleton SIGSEGVs on _updateTransforms. + Ogre::Entity* makeArmRigEntity(const std::string& name) + { + auto skel = Ogre::SkeletonManager::getSingleton().create( + name + "_skel", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + unsigned short h = 0; + auto bone = [&](const char* n, const Ogre::Vector3& p, Ogre::Bone* parent) { + auto* b = skel->createBone(n, h++); + b->setPosition(p); + if (parent) parent->addChild(b); + return b; + }; + auto* hips = bone("Hips", {0, 1.0f, 0}, nullptr); + auto* chest = bone("Spine", {0, 0.3f, 0}, hips); + bone("Head", {0, 0.4f, 0}, chest); + bone("LeftUpLeg", {0.15f, -0.1f, 0}, hips); + bone("RightUpLeg", {-0.15f, -0.1f, 0}, hips); + auto* rsh = bone("RightArm", {-0.2f, 0.1f, 0}, chest); + bone("RightForeArm", {-0.3f, 0, 0}, rsh); + auto* lsh = bone("LeftArm", {0.2f, 0.1f, 0}, chest); + bone("LeftForeArm", {0.3f, 0, 0}, lsh); + skel->setBindingPose(); + auto* anim = skel->createAnimation("clip", 1.0f); + for (unsigned short i = 0; i < skel->getNumBones(); ++i) { + auto* trk = anim->createNodeTrack(i, skel->getBone(i)); + trk->createNodeKeyFrame(0.0f); + trk->createNodeKeyFrame(1.0f); + } + auto mesh = createInMemoryMesh(name + "_mesh", skel); + auto* sm = Manager::getSingleton()->getSceneMgr(); + return sm->createEntity(name + "_ent", mesh); + } + + // World direction of a bone toward its child, at t=0.5 of "clip". + static Ogre::Vector3 armWorldDir(Ogre::SkeletonInstance* skel, + const char* boneName, const char* childName) + { + skel->getAnimation("clip")->apply(skel, 0.5f); + skel->_updateTransforms(); + const Ogre::Vector3 a = skel->getBone(boneName)->_getDerivedPosition(); + const Ogre::Vector3 b = skel->getBone(childName)->_getDerivedPosition(); + return (b - a).normalisedCopy(); + } + static float degBetween(const Ogre::Vector3& a, const Ogre::Vector3& b) + { + return std::acos(std::min(1.0f, std::max(-1.0f, a.dotProduct(b)))) + * 180.0f / static_cast(M_PI); + } }; TEST_F(AnimationMergerTest, CompatibleSkeletons) @@ -966,128 +1018,73 @@ TEST_F(AnimationMergerTest, ExtractCanonicalClipsSamplesWorldFrame) // ── #854: arm-space post-process ──────────────────────────────────────────── -namespace { -// Minimal humanoid with a torso + both arm chains, T-posed (arms out along -// ±X), one identity-keyframe animation on every bone so adjustArmSpace has -// tracks to rewrite. Returns the skeleton (bind pose set) + anim name. -Ogre::SkeletonPtr makeArmRig(const Ogre::String& name) +// These run as TEST_F on the AnimationMergerTest fixture so they can reuse +// createInMemoryMesh — a bare Ogre::Skeleton is NOT safe to apply()/ +// _updateTransforms() on (it SIGSEGVs); the runtime always drives a +// SkeletonInstance obtained from a loaded Entity, so the tests do the same. +TEST_F(AnimationMergerTest, ArmSpaceWidensAndTucksArms) { - auto skel = Ogre::SkeletonManager::getSingleton().create( - name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - unsigned short h = 0; - auto bone = [&](const char* n, const Ogre::Vector3& p, Ogre::Bone* parent) { - auto* b = skel->createBone(n, h++); - b->setPosition(p); - if (parent) parent->addChild(b); - return b; - }; - // spine up +Y, legs for the Ct frame, arms out along ±X (T-pose) - auto* hips = bone("Hips", {0, 1.0f, 0}, nullptr); - auto* chest = bone("Spine", {0, 0.3f, 0}, hips); - auto* head = bone("Head", {0, 0.4f, 0}, chest); - bone("LeftUpLeg", {0.15f, -0.1f, 0}, hips); - bone("RightUpLeg", {-0.15f, -0.1f, 0}, hips); - // right arm out along −X (rig's right), left along +X - auto* rsh = bone("RightArm", {-0.2f, 0.1f, 0}, chest); - bone("RightForeArm", {-0.3f, 0, 0}, rsh); - auto* lsh = bone("LeftArm", {0.2f, 0.1f, 0}, chest); - bone("LeftForeArm", {0.3f, 0, 0}, lsh); - skel->setBindingPose(); - // Identity animation: one keyframe per bone at t=0 and t=1. - auto* anim = skel->createAnimation("clip", 1.0f); - for (unsigned short i = 0; i < skel->getNumBones(); ++i) { - auto* trk = anim->createNodeTrack(i, skel->getBone(i)); - trk->createNodeKeyFrame(0.0f); - trk->createNodeKeyFrame(1.0f); - } - return skel; -} + Ogre::Entity* ent = makeArmRigEntity("armspace_widen"); + ASSERT_NE(ent, nullptr); + Ogre::SkeletonInstance* skel = ent->getSkeleton(); -// World direction of an arm bone (down its length toward the child) at t. -Ogre::Vector3 armWorldDir(Ogre::SkeletonPtr skel, const char* boneName, - const char* childName, float t) -{ - skel->getAnimation("clip")->apply(skel.get(), t); - skel->_updateTransforms(); - const Ogre::Vector3 a = skel->getBone(boneName)->_getDerivedPosition(); - const Ogre::Vector3 b = skel->getBone(childName)->_getDerivedPosition(); - return (b - a).normalisedCopy(); -} -} // namespace + const Ogre::Vector3 rBase = armWorldDir(skel, "RightArm", "RightForeArm"); + const Ogre::Vector3 lBase = armWorldDir(skel, "LeftArm", "LeftForeArm"); -TEST(AnimationMergerArmSpaceTest, WidensAndTucksArms) -{ - auto skel = makeArmRig("armspace_widen"); - const Ogre::Vector3 rBase = - armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); - - // +30° must widen: the right arm's downward (−Y) component increases as it - // swings out/down in the frontal plane; the sideways spread grows. - ASSERT_TRUE(AnimationMerger::adjustArmSpace(skel.get(), "clip", 30.0f)); - const Ogre::Vector3 rWide = - armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); - // The arm actually moved by ~30° about the forward (Z) axis. - const float ang = std::acos(std::min(1.0f, rBase.dotProduct(rWide))) - * 180.0f / static_cast(M_PI); - EXPECT_NEAR(ang, 30.0f, 4.0f); - - // Symmetry: the left arm swings by the same angle, mirrored. - auto skelL = makeArmRig("armspace_widenL"); - const Ogre::Vector3 lBase = - armWorldDir(skelL, "LeftArm", "LeftForeArm", 0.5f); - AnimationMerger::adjustArmSpace(skelL.get(), "clip", 30.0f); - const Ogre::Vector3 lWide = - armWorldDir(skelL, "LeftArm", "LeftForeArm", 0.5f); - const float angL = std::acos(std::min(1.0f, lBase.dotProduct(lWide))) - * 180.0f / static_cast(M_PI); - EXPECT_NEAR(angL, 30.0f, 4.0f); + // +30° swings each arm ~30° about the torso forward axis (widen). + ASSERT_TRUE(AnimationMerger::adjustArmSpace(skel, "clip", 30.0f)); + const float angR = degBetween(rBase, armWorldDir(skel, "RightArm", "RightForeArm")); + const float angL = degBetween(lBase, armWorldDir(skel, "LeftArm", "LeftForeArm")); + EXPECT_NEAR(angR, 30.0f, 5.0f); + EXPECT_NEAR(angL, 30.0f, 5.0f); // symmetric per side } -TEST(AnimationMergerArmSpaceTest, IdempotentAbsoluteApplication) +TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) { - auto skel = makeArmRig("armspace_idem"); - const Ogre::Vector3 base = - armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); - - // Apply 20 then 10 → must equal applying 10 from the original (absolute). - AnimationMerger::adjustArmSpace(skel.get(), "clip", 20.0f); - AnimationMerger::adjustArmSpace(skel.get(), "clip", 10.0f); - const Ogre::Vector3 at10 = - armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); - - auto ref = makeArmRig("armspace_idem_ref"); - AnimationMerger::adjustArmSpace(ref.get(), "clip", 10.0f); + Ogre::Entity* ent = makeArmRigEntity("armspace_idem"); + ASSERT_NE(ent, nullptr); + Ogre::SkeletonInstance* skel = ent->getSkeleton(); + const Ogre::Vector3 base = armWorldDir(skel, "RightArm", "RightForeArm"); + + // 20 then 10 == 10 from the original (absolute, reverts the prior value). + AnimationMerger::adjustArmSpace(skel, "clip", 20.0f); + AnimationMerger::adjustArmSpace(skel, "clip", 10.0f); + const Ogre::Vector3 at10 = armWorldDir(skel, "RightArm", "RightForeArm"); + EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 10.0f); + + Ogre::Entity* ref = makeArmRigEntity("armspace_idem_ref"); + ASSERT_NE(ref, nullptr); + AnimationMerger::adjustArmSpace(ref->getSkeleton(), "clip", 10.0f); const Ogre::Vector3 ref10 = - armWorldDir(ref, "RightArm", "RightForeArm", 0.5f); - EXPECT_GT(at10.dotProduct(ref10), 0.9999f); // same direction - - // Back to 0 restores the original bind-referenced pose bit-near-exactly. - AnimationMerger::adjustArmSpace(skel.get(), "clip", 0.0f); - const Ogre::Vector3 restored = - armWorldDir(skel, "RightArm", "RightForeArm", 0.5f); - EXPECT_GT(base.dotProduct(restored), 0.99999f); + armWorldDir(ref->getSkeleton(), "RightArm", "RightForeArm"); + EXPECT_GT(at10.dotProduct(ref10), 0.9999f); + + // Back to 0 restores the original pose bit-near-exactly. + AnimationMerger::adjustArmSpace(skel, "clip", 0.0f); + EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 0.0f); + EXPECT_GT(base.dotProduct(armWorldDir(skel, "RightArm", "RightForeArm")), + 0.99999f); } -TEST(AnimationMergerArmSpaceTest, LeavesNonArmBonesUntouched) +TEST_F(AnimationMergerTest, ArmSpaceLeavesNonArmBonesUntouched) { - auto skel = makeArmRig("armspace_legs"); - const Ogre::Vector3 legBase = - armWorldDir(skel, "Hips", "LeftUpLeg", 0.5f); - const Ogre::Vector3 spineBase = - armWorldDir(skel, "Hips", "Spine", 0.5f); - - AnimationMerger::adjustArmSpace(skel.get(), "clip", 40.0f); - const Ogre::Vector3 legAfter = - armWorldDir(skel, "Hips", "LeftUpLeg", 0.5f); - const Ogre::Vector3 spineAfter = - armWorldDir(skel, "Hips", "Spine", 0.5f); - EXPECT_GT(legBase.dotProduct(legAfter), 0.99999f); - EXPECT_GT(spineBase.dotProduct(spineAfter), 0.99999f); + Ogre::Entity* ent = makeArmRigEntity("armspace_legs"); + ASSERT_NE(ent, nullptr); + Ogre::SkeletonInstance* skel = ent->getSkeleton(); + const Ogre::Vector3 legBase = armWorldDir(skel, "Hips", "LeftUpLeg"); + const Ogre::Vector3 spineBase = armWorldDir(skel, "Hips", "Spine"); + + AnimationMerger::adjustArmSpace(skel, "clip", 40.0f); + EXPECT_GT(legBase.dotProduct(armWorldDir(skel, "Hips", "LeftUpLeg")), + 0.99999f); + EXPECT_GT(spineBase.dotProduct(armWorldDir(skel, "Hips", "Spine")), + 0.99999f); } -TEST(AnimationMergerArmSpaceTest, NoOpWhenAnimationMissing) +TEST_F(AnimationMergerTest, ArmSpaceNoOpWhenAnimationMissing) { - auto skel = makeArmRig("armspace_missing"); - EXPECT_FALSE(AnimationMerger::adjustArmSpace(skel.get(), "nope", 20.0f)); + Ogre::Entity* ent = makeArmRigEntity("armspace_missing"); + ASSERT_NE(ent, nullptr); + EXPECT_FALSE(AnimationMerger::adjustArmSpace(ent->getSkeleton(), "nope", 20.0f)); EXPECT_FALSE(AnimationMerger::adjustArmSpace(nullptr, "clip", 20.0f)); } From 3e3c9df7beab39704cc7479935208282f6b61caa Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 11 Jul 2026 23:22:28 -0400 Subject: [PATCH 03/16] fix(anim): carry arm-space value across animation rename (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a clip orphaned its arm-space widen/tuck value: the applied-angle map is keyed by (skeleton, animation name), so after a rename currentArmSpace() reported 0 for the renamed clip even though its keyframes were still widened — and the next slider drag then computed an absolute-from-0 delta and over-rotated. Added AnimationMerger::migrateArmSpaceKey(skeletonName, old, new) and call it from BOTH rename paths — AnimationMerger::renameAnimation (CLI/merge) and SkeletonTransform::renameAnimation (the GUI Inspector double-click rename) — right where the keyframes are copied to the new name. SkeletonInstance:: getName() returns the shared skeleton's name, so the key matches what adjustArmSpace stored regardless of which path set it. Unit test: adjust to 25°, rename, confirm the angle follows to the new name (and the old key is cleared), and that a follow-up adjust to 0 on the new name correctly restores the bind pose (proving the delta is computed from 25, not 0). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AnimationMerger.cpp | 19 +++++++++++++++++++ src/AnimationMerger.h | 9 +++++++++ src/AnimationMerger_test.cpp | 33 ++++++++++++++++++++++++++++++--- src/SkeletonTransform.cpp | 6 ++++++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index ef3f4942..f6a666d0 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -239,9 +239,28 @@ void AnimationMerger::renameAnimation(Ogre::Skeleton* skel, } } + // #854: carry the arm-space applied-angle over to the new name — the + // widened keyframes were copied above, so the tracked angle must follow + // or currentArmSpace() would report 0 for the renamed clip and the next + // slider drag would compute a wrong (absolute-from-0) delta. + migrateArmSpaceKey(skel->getName(), oldName, newName); + skel->removeAnimation(oldName); } +void AnimationMerger::migrateArmSpaceKey(const std::string& skeletonName, + const std::string& oldAnim, + const std::string& newAnim) +{ + if (oldAnim == newAnim) return; + const std::pair oldKey(skeletonName, oldAnim); + if (auto it = g_armSpaceApplied.find(oldKey); + it != g_armSpaceApplied.end()) { + g_armSpaceApplied[{skeletonName, newAnim}] = it->second; + g_armSpaceApplied.erase(it); + } +} + int AnimationMerger::resampleAnimation(Ogre::Skeleton* skel, const std::string& animName, int targetKeyframes) diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index b6b17517..5894de62 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -244,6 +244,15 @@ class AnimationMerger { static float currentArmSpace(Ogre::Skeleton* skel, const std::string& animName); + /// Move the tracked arm-space angle from oldAnim to newAnim on the given + /// skeleton. Call from every animation-rename path so a renamed clip keeps + /// its widen/tuck value (the keyframes carry over; the tracked angle must + /// too, or the next slider drag mis-computes its delta). No-op if there was + /// no tracked angle or the names match. + static void migrateArmSpaceKey(const std::string& skeletonName, + const std::string& oldAnim, + const std::string& newAnim); + /// Sample every (or one) skeletal animation of `entity` at `fps` and /// express each canonical joint's world orientation per frame. Bone→role /// mapping is MotionInbetween::canonicalIndexForBone — the same matcher diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index d43f8fc9..9b47e62e 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -135,11 +135,12 @@ class AnimationMergerTest : public ::testing::Test { return sm->createEntity(name + "_ent", mesh); } - // World direction of a bone toward its child, at t=0.5 of "clip". + // World direction of a bone toward its child, at t=0.5 of `anim`. static Ogre::Vector3 armWorldDir(Ogre::SkeletonInstance* skel, - const char* boneName, const char* childName) + const char* boneName, const char* childName, + const char* anim = "clip") { - skel->getAnimation("clip")->apply(skel, 0.5f); + skel->getAnimation(anim)->apply(skel, 0.5f); skel->_updateTransforms(); const Ogre::Vector3 a = skel->getBone(boneName)->_getDerivedPosition(); const Ogre::Vector3 b = skel->getBone(childName)->_getDerivedPosition(); @@ -1088,3 +1089,29 @@ TEST_F(AnimationMergerTest, ArmSpaceNoOpWhenAnimationMissing) EXPECT_FALSE(AnimationMerger::adjustArmSpace(ent->getSkeleton(), "nope", 20.0f)); EXPECT_FALSE(AnimationMerger::adjustArmSpace(nullptr, "clip", 20.0f)); } + +TEST_F(AnimationMergerTest, ArmSpaceFollowsAnimationRename) +{ + Ogre::Entity* ent = makeArmRigEntity("armspace_rename"); + ASSERT_NE(ent, nullptr); + Ogre::SkeletonInstance* skel = ent->getSkeleton(); + + AnimationMerger::adjustArmSpace(skel, "clip", 25.0f); + ASSERT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 25.0f); + + // Rename the clip — the tracked angle must move with it (and the old key + // must be gone), so a UI targeting the renamed clip sees 25, not 0. + AnimationMerger::renameAnimation(skel, "clip", "walk_wide"); + EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "walk_wide"), 25.0f); + EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 0.0f); + + // And a follow-up adjust on the new name computes its delta from 25: + // going back to 0 restores the bind pose (would over-rotate if it thought + // the clip were at 0). + const Ogre::Vector3 wide = armWorldDir(skel, "RightArm", "RightForeArm", + "walk_wide"); + AnimationMerger::adjustArmSpace(skel, "walk_wide", 0.0f); + const Ogre::Vector3 neutral = armWorldDir(skel, "RightArm", "RightForeArm", + "walk_wide"); + EXPECT_GT(degBetween(wide, neutral), 15.0f); // it actually moved back +} diff --git a/src/SkeletonTransform.cpp b/src/SkeletonTransform.cpp index 1c5d2c49..cf095cc3 100755 --- a/src/SkeletonTransform.cpp +++ b/src/SkeletonTransform.cpp @@ -27,6 +27,7 @@ THE SOFTWARE. */ #include "SkeletonTransform.h" +#include "AnimationMerger.h" #include #include @@ -157,6 +158,11 @@ bool SkeletonTransform::renameAnimation(Ogre::Entity *_ent, const QString &_oldN } } + // #854: keep the arm-space widen/tuck value tied to the renamed clip + // (the keyframes were copied above; migrate the tracked angle too). + AnimationMerger::migrateArmSpaceKey( + sk->getName(), _oldName.toStdString(), _newName.toStdString()); + //Remove the old animation sk->removeAnimation(_oldName.toStdString()); From ceaedfaef0cc420a309b0c9567e91a569f64d8f9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sat, 11 Jul 2026 23:33:29 -0400 Subject: [PATCH 04/16] fix(anim): address arm-space review feedback (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review (CodeRabbit + Codex) on PR #867: - Stale cache on regenerate (Codex P2): applyMotionClip now erases the arm-space map entry when it removes/recreates a generated_* clip, so re-requesting the same angle on the fresh keyframes isn't a delta-0 no-op. - MCP export used the SkeletonInstance (Codex P2): adjust_arm_space now edits the mesh's MASTER skeleton — the exporter serializes the master, so output_path files now actually contain the adjustment. - Breadcrumb categories: GUI adjustArmSpace → ui.action; CLI + MCP arm-space → ai.tool_call (per repo conventions; was text_to_motion). - MCP generate_motion echoes arm_space_applied so a caller can tell the rig had no arm roles; adjust_arm_space now gives the two-branch entity-not-found message (omitted vs non-matching) like generate_motion. - Docs: corrected the CLAUDE.md absolute-angle example (+45 then −45 = −45, only 0 restores base) and the header contract (session-local static map, not UserObjectBindings; not persisted; CLI-process caveat). Also documents the regenerate-clear, rename-migration, live/paused GUI, and any-clip targeting added since the first commit. - Test: WidensAndTucksArms now asserts the arms swing to OPPOSITE sides (sagittal-mirror + per-side X sign), catching a sign regression that equal magnitudes alone would pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src/AnimationControlController.cpp | 2 +- src/AnimationMerger.cpp | 4 ++++ src/AnimationMerger.h | 18 +++++++++++++----- src/AnimationMerger_test.cpp | 20 ++++++++++++++++---- src/CLIPipeline.cpp | 4 ++-- src/MCPServer.cpp | 27 +++++++++++++++++++-------- 7 files changed, 56 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc8c1e99..f8e445bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -376,7 +376,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **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 --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`). - **MotionLibrary / text-to-motion** (`src/MotionLibrary.h/cpp`, issue #411, experimental): generate a skeletal animation from a text prompt. **The #411 spike (see `docs/TEXT_TO_MOTION_SPIKE_411.md`) proved a from-scratch GENERATIVE model (MDM-style) collapses to a static pose without multi-day ML effort, and all off-the-shelf models (MDM/T2M-GPT/MotionGPT) train on AMASS-derived HumanML3D/KIT-ML = non-commercial (the LAFAN1/ShapeNet wall again).** So the SHIPPED feature is a **template-clip MVP**: a curated library of permissive **CMU MoCap** clips (commercial-OK, same source as #409 RMIB), matched to the prompt by action keyword + synonyms (`MotionLibrary::matchPrompt`), then retargeted onto the user's rig via `AnimationMerger::applyMotionClip` → `MotionInbetween::canonicalIndexForBone` (the SAME 22-joint canonical mapping as #409). `MotionLibrary` is Ogre-free + unit-tested (`MotionLibrary_test.cpp`): parses `qtmesh-motion-library-v1`/`v2` JSON (per-frame, per-joint canonical quats; v2 adds an optional 22-entry `cmuRestWorld` block) + keyword matching. Library downloads on first use to `AppData/ai_models/motion/` (override `QTMESH_MOTION_LIBRARY_BASE_URL` / `QSettings ai/motionLibraryBaseUrl`; offline guard `QTMESH_MOTION_NO_DOWNLOAD`) — built offline by `scripts/build-motion-library.py` (10 actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle, ~0.9 MB), hosted on the [`fernandotonon/QtMeshEditor-models`](https://huggingface.co/fernandotonon/QtMeshEditor-models) HF repo under `motion/`. **Retarget (`applyMotionClip`) — the part that makes it look right:** the CMU clip stores each joint's LOCAL (parent-relative) rotation with rest ≈ identity (the rest DIRECTION is in the BVH bone offsets, captured as the v2 `cmuRestWorld` per-joint world-rest `Wcmu`). The exact per-bone formula is `local(f) = parentWorld⁻¹ · (Wcmu · clip(f) · Wcmu⁻¹) · parentWorld · bind`, where `bind` = the rig's STANDING pose harvested from frame-0 of its existing animation (Mixamo bone rest is identity — the standing pose lives in the anim, NOT in `getInitial{Orientation,Position}` which is inflated and would stretch the mesh), and the root (hip) is locked to the standing pose (CMU bakes whole-body facing into the root). The `Wcmu·clip·Wcmu⁻¹` conjugation is the CMU↔target change-of-basis that cancels the per-bone ROLL twist between rigs with different bone axes (Mixamo arms point down their length / sideways; UniRig is axis-aligned). v1 libraries (no `cmuRestWorld`) fall back to `Wcmu`=identity (parent-world transport only — direction-correct, residual roll). Writes rotation-only keyframes (translation/scale stay at the standing pose, preserving rig proportions) and requires ≥½ of the 22 canonical roles to resolve (else fails — not a humanoid rig). Render-verified on the Rumba (Mixamo) rig via the isometric loop: walk = upright stride with arms hanging+swinging; wave = upright + natural. Surfaced via **CLI `qtmesh anim --generate "" [--duration N] [-o out]`** (`CLIPipeline::cmdAnimGenerate`), the **MCP `generate_motion` tool** (`MCPServer::toolGenerateMotion`, args `{prompt, entity_name?, duration?, output_path?}`, registered heavy), and the **Animation panel "Generate from text" control** (`qml/AnimationControlPanel.qml` → `AnimationControlController::generateMotion`, emits `generateMotionStatus`). Sentry breadcrumb `ai.assist.text_to_motion`. **v4 library** (July 2026): clips are the trial's ACTIVE window (max motion energy, start snapped to a calm near-neutral frame — the retarget deltas against clip frame 0), replacing first-4s slices that mostly captured idle lead-ins; 13 actions (adds sit/throw/boxing; 'idle' now a real wait trial — the old 69_01 source walked; 'dance' is salsa — ballet pirouettes fold under the locked root). **Generative path (opt-in `--model` / `model:true` / GUI checkbox): `MotionGenerator` + `motion/t2m.onnx`** — a CVAE transformer trained from scratch on the same CMU source (`scripts/prep-t2m-v4.py` + `scripts/train-t2m-onnx-v4.py`, offline): 30fps WORLD-frame windows w/ neutral starts (the v3 model trained on raw-120fps 0.33s local-frame windows and folded/flailed at 3.5x real velocity), absolute-pose decoder (no error-accumulating delta-cumsum), per-sample + rotation-space (geodesic) velocity matching, derived-local supervision (parent^-1*child — the exact quantity applyMotionClip renders; world-only losses let spine-chain errors stack into a visible fold), and z=0 latent-dropout supervision (the app infers with seed=zeros; an unsupervised z=0 is out-of-distribution for a low-beta CVAE). The vocab json declares `frame:world` + `fps`, read by `MotionGenerator::generate` → `Result::worldFrame` → applyMotionClip, so model clips ride the same world retarget as v3 template clips. Template library stays the default + automatic fallback. **Quality limit:** the model's z=0 output is smooth/upright but gentler than real clips (conditional-mean effect; the medoid-exemplar alternative is crisper numerically but renders twisted — `--z0-target` flag documents both); the template path is the quality bar. -- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on a generated clip to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Idempotent + absolute**: a session-scoped `std::map<(skeleton,anim),float>` tracks the last-applied angle; re-applying reverts it first (delta = new−stored), so a slider maps directly to the value and `adjustArmSpace(0)` restores the clip bit-exactly (verified: `+45` then `−45` == base, max quat diff 0.0). Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace`), and the **Inspector Animations "Arm space" slider** (−30…+45°, `qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`, applies on release to the last generated clip). Unit-tested in `AnimationMerger_test.cpp` (swing angle, symmetry, idempotence, non-arm invariance). Sentry breadcrumb `ai.assist.text_to_motion` (`arm_space`). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. +- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on ANY animation (not just generated ones) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Absolute + idempotent**: a **session-local** `std::map<(skeletonName,animName),float>` tracks the last-applied angle (NOT UserObjectBindings — `Ogre::Animation` has none; NOT persisted — export bakes the final keyframes), and each call reverts it first (delta = new−stored). So the angle is ABSOLUTE: `+45` leaves the clip at +45, a later `−45` leaves it at −45 (net = −45), and only `adjustArmSpace(0)` restores the base pose bit-near-exactly. `currentArmSpace(skel, anim)` exposes the stored value so the GUI seeds its slider with the clip's real state. `applyMotionClip` erases the entry when it (re)creates a `generated_*` clip so a regenerated clip starts unadjusted. `migrateArmSpaceKey` moves the entry on rename — called from BOTH `AnimationMerger::renameAnimation` (CLI/merge) and `SkeletonTransform::renameAnimation` (GUI) so a renamed widened clip keeps its value. Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` (response echoes `arm_space_applied`) + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace` — edits the mesh's MASTER skeleton so `output_path` export includes the change), and the **Inspector Animations section**: a live "Arm space" slider (−30…+45°) plus a per-row `↔` button that targets any clip (`qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`/`currentArmSpace`). The slider updates the viewport LIVE while dragging, even when the clip is PAUSED (`_notifyDirty` + re-stamp the state's time); generation never bakes the slider value in. Unit-tested in `AnimationMerger_test.cpp` (swing angle, mirrored per-side direction, absolute/idempotent via `currentArmSpace`, non-arm invariance, rename migration). Sentry breadcrumbs: `ui.action` (GUI) / `ai.tool_call` (CLI + MCP). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. - **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. diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index d37cd05c..ab079ff9 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1677,7 +1677,7 @@ bool AnimationControlController::adjustArmSpace(const QString& animName, const std::string an = animName.toStdString(); if (!skel || !skel->hasAnimation(an)) return false; - SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("GUI arm_space %1 deg").arg(degrees)); if (!AnimationMerger::adjustArmSpace(skel.get(), an, static_cast(degrees))) diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index f6a666d0..5b38a06a 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -1546,6 +1546,10 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip( if (skel->hasAnimation(animName)) skel->removeAnimation(animName); + // #854: the freshly (re)created clip has NO arm-space applied. A stale + // entry from a prior generation of the same name would make a re-request + // of that same angle a no-op (delta 0) on the new keyframes — forget it. + g_armSpaceApplied.erase({skel->getName(), animName}); Ogre::Animation* anim = skel->createAnimation(animName, length); anim->setInterpolationMode(Ogre::Animation::IM_LINEAR); anim->setRotationInterpolationMode(Ogre::Animation::RIM_LINEAR); diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index 5894de62..10151f20 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -227,11 +227,19 @@ class AnimationMerger { /// is split across duplicate role bones so multi-segment shoulders don't /// over-rotate. /// - /// IDEMPOTENT: the net applied angle is stored on the animation via - /// UserObjectBindings ("qtme.armspace"); re-applying first reverts the - /// stored angle, so `adjustArmSpace(20)` then `adjustArmSpace(10)` == - /// `adjustArmSpace(10)` from the original, and `adjustArmSpace(0)` - /// restores the clip bit-near-exactly. `degrees` is the ABSOLUTE target. + /// ABSOLUTE + IDEMPOTENT: `degrees` is the target angle, not a nudge. The + /// last-applied angle is tracked in a SESSION-LOCAL static map keyed by + /// (skeleton name, animation name) — Ogre::Animation has no + /// UserObjectBindings, and the value is NOT persisted (export bakes the + /// final keyframes). Each call reverts the stored angle before applying + /// the new one (delta = new − stored), so `adjustArmSpace(20)` then + /// `adjustArmSpace(10)` == `adjustArmSpace(10)` from the original, and + /// `adjustArmSpace(0)` restores the clip bit-near-exactly. NB: because the + /// map is session-local, a fresh CLI process sees `stored == 0`, so + /// re-opening an already-exported widened clip and calling + /// `adjustArmSpace(0)` is a no-op (the keyframes are baked). Use + /// currentArmSpace() to read the tracked value; applyMotionClip clears it + /// when it regenerates a clip, and migrateArmSpaceKey() moves it on rename. /// Returns false (no-op) if the animation is missing or no arm role /// resolves on the rig. static bool adjustArmSpace(Ogre::Skeleton* skel, diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 9b47e62e..81d74035 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1034,10 +1034,22 @@ TEST_F(AnimationMergerTest, ArmSpaceWidensAndTucksArms) // +30° swings each arm ~30° about the torso forward axis (widen). ASSERT_TRUE(AnimationMerger::adjustArmSpace(skel, "clip", 30.0f)); - const float angR = degBetween(rBase, armWorldDir(skel, "RightArm", "RightForeArm")); - const float angL = degBetween(lBase, armWorldDir(skel, "LeftArm", "LeftForeArm")); - EXPECT_NEAR(angR, 30.0f, 5.0f); - EXPECT_NEAR(angL, 30.0f, 5.0f); // symmetric per side + const Ogre::Vector3 rWide = armWorldDir(skel, "RightArm", "RightForeArm"); + const Ogre::Vector3 lWide = armWorldDir(skel, "LeftArm", "LeftForeArm"); + EXPECT_NEAR(degBetween(rBase, rWide), 30.0f, 5.0f); + EXPECT_NEAR(degBetween(lBase, lWide), 30.0f, 5.0f); + + // MIRROR check — the two arms must swing in OPPOSITE X directions (out to + // their own sides), not the same way. On this symmetric rig the widened + // directions must be near-mirror-images across the sagittal (X=0) plane: + // reflecting the right result's X should match the left. This catches a + // per-side sign regression that equal magnitudes alone would pass. + const Ogre::Vector3 rMirrored(-rWide.x, rWide.y, rWide.z); + EXPECT_GT(rMirrored.dotProduct(lWide), 0.99f) + << "arms did not swing to opposite sides (sign regression)"; + // And widening moves them apart: the right arm goes more −X, left more +X. + EXPECT_LT(rWide.x, rBase.x); + EXPECT_GT(lWide.x, lBase.x); } TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index e83e9dd4..e4ac76fb 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -2125,7 +2125,7 @@ int CLIPipeline::cmdAnimGenerate(const QString& filePath, const QString& prompt, if (std::abs(armSpaceDeg) > 1e-4f) { if (AnimationMerger::adjustArmSpace(skel.get(), animName, armSpaceDeg)) { SentryReporter::addBreadcrumb( - QStringLiteral("ai.assist.text_to_motion"), + QStringLiteral("ai.tool_call"), QStringLiteral("arm_space %1 deg").arg(armSpaceDeg)); err() << "(arm-space: " << armSpaceDeg << " deg)" << Qt::endl; } else @@ -2363,7 +2363,7 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) << Qt::endl; return 1; } - SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), + SentryReporter::addBreadcrumb(QStringLiteral("ai.tool_call"), QStringLiteral("arm_space %1 deg").arg(armSpaceDeg)); const QString out = outputPath.isEmpty() ? filePath : outputPath; auto* node = entity->getParentSceneNode(); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 61f38faf..0dfd9f00 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4119,11 +4119,14 @@ QJsonObject MCPServer::toolGenerateMotion(const QJsonObject &args) clipDirs); if (!r.ok) return makeErrorResult(QString("Error: %1").arg(r.error)); - // #854: optional Mixamo-style arm-space post-process. + // #854: optional Mixamo-style arm-space post-process. Echo whether it + // took effect so an MCP caller can tell the rig had no arm roles + // (rather than silently getting an unadjusted clip). const double armSpace = args.value("arm_space").toDouble(0.0); + bool armSpaceApplied = false; if (std::abs(armSpace) > 1e-4) - AnimationMerger::adjustArmSpace(skel.get(), animName, - static_cast(armSpace)); + armSpaceApplied = AnimationMerger::adjustArmSpace( + skel.get(), animName, static_cast(armSpace)); entity->refreshAvailableAnimationState(); // Exclusively enable the generated clip — enabled states BLEND in @@ -4159,6 +4162,7 @@ QJsonObject MCPServer::toolGenerateMotion(const QJsonObject &args) content["frames"] = r.frames; content["length"] = r.length; content["tracks_written"] = r.tracksWritten; content["canonical_joints"] = r.canonicalJoints; content["entity"] = QString::fromStdString(entity->getName()); + if (std::abs(armSpace) > 1e-4) content["arm_space_applied"] = armSpaceApplied; if (!outPath.isEmpty()) content["exported"] = outPath; return makeSuccessResult( QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); @@ -4173,7 +4177,7 @@ QJsonObject MCPServer::toolGenerateMotion(const QJsonObject &args) QJsonObject MCPServer::toolAdjustArmSpace(const QJsonObject &args) { try { - SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.text_to_motion"), + SentryReporter::addBreadcrumb(QStringLiteral("ai.tool_call"), QStringLiteral("MCP adjust_arm_space")); Manager* mgr = Manager::getSingletonPtr(); @@ -4196,15 +4200,22 @@ QJsonObject MCPServer::toolAdjustArmSpace(const QJsonObject &args) } } if (!entity) - return makeErrorResult("Error: no matching rigged entity."); - - Ogre::SkeletonInstance* skel = entity->getSkeleton(); + return makeErrorResult(entityName.isEmpty() + ? QString("Error: no skinned mesh found to adjust.") + : QString("Error: skinned entity '%1' not found.").arg(entityName)); + + // Edit the mesh's MASTER skeleton, not the entity's SkeletonInstance: + // the exporter serializes the master (same as every other animation- + // edit tool), so editing the instance would return success while the + // written file kept the unadjusted clip. Animations are shared between + // master and instance, so the live viewport still updates. + Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton(); const std::string an = animName.toStdString(); if (!skel || !skel->hasAnimation(an)) return makeErrorResult( QString("Error: animation '%1' not found on entity.").arg(animName)); - if (!AnimationMerger::adjustArmSpace(skel, an, + if (!AnimationMerger::adjustArmSpace(skel.get(), an, static_cast(degrees))) return makeErrorResult( "Error: arm-space adjustment failed (no arm roles on this rig)."); From c24f8b6e4b25f2041954ee92be48db1ddb238885 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 00:11:58 -0400 Subject: [PATCH 05/16] test(anim): correct arm-space geometry assertions + debug trace (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture rewrite fixed the SIGSEGV; the remaining failures were over-specified geometry expectations (a T-posed arm swung about the forward axis reduces |X| rather than increasing it). Rewrote the mirror check to assert sagittal symmetry + per-side X sign, and added a temporary stderr trace to the idempotence test to capture the real vectors from CI (a bare Skeleton can't run locally — no GL). --- src/AnimationMerger_test.cpp | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 81d74035..5461c3a6 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1039,17 +1039,24 @@ TEST_F(AnimationMergerTest, ArmSpaceWidensAndTucksArms) EXPECT_NEAR(degBetween(rBase, rWide), 30.0f, 5.0f); EXPECT_NEAR(degBetween(lBase, lWide), 30.0f, 5.0f); - // MIRROR check — the two arms must swing in OPPOSITE X directions (out to - // their own sides), not the same way. On this symmetric rig the widened - // directions must be near-mirror-images across the sagittal (X=0) plane: - // reflecting the right result's X should match the left. This catches a - // per-side sign regression that equal magnitudes alone would pass. + // MIRROR check — the swing must be mirrored across the sagittal (X=0) + // plane, not applied the same way to both arms (that would rotate the + // whole shoulder line rigidly instead of spreading). Rotating about the + // forward (Z) axis moves each arm in the X-Y plane; a per-side sign + // regression shows up as the two arms picking up OPPOSITE-signed Y + // (one lifts, one drops) instead of the same sign. Reflecting the right + // result's X must match the left result (both components), which only + // holds when the per-side rotation signs are correct. const Ogre::Vector3 rMirrored(-rWide.x, rWide.y, rWide.z); - EXPECT_GT(rMirrored.dotProduct(lWide), 0.99f) - << "arms did not swing to opposite sides (sign regression)"; - // And widening moves them apart: the right arm goes more −X, left more +X. - EXPECT_LT(rWide.x, rBase.x); - EXPECT_GT(lWide.x, lBase.x); + EXPECT_GT(rMirrored.dotProduct(lWide), 0.98f) + << "arms did not swing symmetrically (per-side sign regression): " + << "R=(" << rWide.x << "," << rWide.y << "," << rWide.z << ") " + << "L=(" << lWide.x << "," << lWide.y << "," << lWide.z << ")"; + // The arms stay on their own sides (right −X, left +X) throughout. + EXPECT_LT(rWide.x, 0.0f); + EXPECT_GT(lWide.x, 0.0f); + // Symmetric magnitude: |Δ| the same on both sides. + EXPECT_NEAR(degBetween(rBase, rWide), degBetween(lBase, lWide), 1.0f); } TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) @@ -1075,8 +1082,12 @@ TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) // Back to 0 restores the original pose bit-near-exactly. AnimationMerger::adjustArmSpace(skel, "clip", 0.0f); EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 0.0f); - EXPECT_GT(base.dotProduct(armWorldDir(skel, "RightArm", "RightForeArm")), - 0.99999f); + const Ogre::Vector3 restored = armWorldDir(skel, "RightArm", "RightForeArm"); + fprintf(stderr, "[armspace-dbg] base=(%.3f,%.3f,%.3f) at10=(%.3f,%.3f,%.3f) " + "ref10=(%.3f,%.3f,%.3f) restored=(%.3f,%.3f,%.3f)\n", + base.x, base.y, base.z, at10.x, at10.y, at10.z, + ref10.x, ref10.y, ref10.z, restored.x, restored.y, restored.z); + EXPECT_GT(base.dotProduct(restored), 0.99999f); } TEST_F(AnimationMergerTest, ArmSpaceLeavesNonArmBonesUntouched) From 13130345de4e5c14a6b1b013c3fc8bf91a21a534 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 00:51:21 -0400 Subject: [PATCH 06/16] test(anim): finer arm-space idempotence trace (#854, debug) --- src/AnimationMerger_test.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 5461c3a6..ef3bdbb4 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1068,8 +1068,13 @@ TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) // 20 then 10 == 10 from the original (absolute, reverts the prior value). AnimationMerger::adjustArmSpace(skel, "clip", 20.0f); + const Ogre::Vector3 after20 = armWorldDir(skel, "RightArm", "RightForeArm"); AnimationMerger::adjustArmSpace(skel, "clip", 10.0f); const Ogre::Vector3 at10 = armWorldDir(skel, "RightArm", "RightForeArm"); + fprintf(stderr, "[armspace-dbg2] after20=(%.3f,%.3f,%.3f) deg=%.1f | " + "at10=(%.3f,%.3f,%.3f) deg=%.1f\n", + after20.x, after20.y, after20.z, degBetween(base, after20), + at10.x, at10.y, at10.z, degBetween(base, at10)); EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 10.0f); Ogre::Entity* ref = makeArmRigEntity("armspace_idem_ref"); From 5147bf6fee0e4098623e6669766c022dc5e02347 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 01:08:37 -0400 Subject: [PATCH 07/16] fix(anim): invalidate track cache after arm-space keyframe edits (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic unit tests exposed a real off-by-one bug: adjustArmSpace rewrote keyframe rotations via TransformKeyFrame::setRotation, which does NOT invalidate NodeAnimationTrack's interpolation caches. The next apply() replayed the pre-edit rotations, so each adjust appeared to lag one call behind — 20° then 10° landed at 20°, and →0 left the clip at 30°. In the live GUI this was masked by the render loop's continuous re-apply and the paused re-pose (_notifyDirty), but a single deterministic evaluation caught it (CI trace: at10 at 20° instead of 10°, restored at 30° instead of base). Fix: call track->_keyFrameDataChanged() after editing each track so the next evaluation rebuilds from the new keyframes. Now absolute + idempotent holds exactly: 20 then 10 == 10, and →0 restores the base pose. Removed the debug traces and pinned the test assertions to the correct geometry (a T-posed arm swung about the forward axis moves toward ±Y, reducing |X|; both arms mirror across the sagittal plane). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AnimationMerger.cpp | 6 ++++++ src/AnimationMerger_test.cpp | 14 +++----------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index 5b38a06a..428e75ee 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -1440,6 +1440,12 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, Ogre::TransformKeyFrame* kf = trk->getNodeKeyFrame(k); kf->setRotation(L * kf->getRotation()); } + // TransformKeyFrame::setRotation does NOT invalidate the track's + // interpolation caches (rotation spline / derived data), so a + // following apply() would replay the PRE-edit rotations — the edit + // appears to lag one call behind. Flag the track dirty so the next + // evaluation rebuilds from the new keyframes. + trk->_keyFrameDataChanged(); touchedAny = true; } diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index ef3bdbb4..7d5ecf09 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1068,14 +1068,10 @@ TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) // 20 then 10 == 10 from the original (absolute, reverts the prior value). AnimationMerger::adjustArmSpace(skel, "clip", 20.0f); - const Ogre::Vector3 after20 = armWorldDir(skel, "RightArm", "RightForeArm"); AnimationMerger::adjustArmSpace(skel, "clip", 10.0f); const Ogre::Vector3 at10 = armWorldDir(skel, "RightArm", "RightForeArm"); - fprintf(stderr, "[armspace-dbg2] after20=(%.3f,%.3f,%.3f) deg=%.1f | " - "at10=(%.3f,%.3f,%.3f) deg=%.1f\n", - after20.x, after20.y, after20.z, degBetween(base, after20), - at10.x, at10.y, at10.z, degBetween(base, at10)); EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 10.0f); + EXPECT_NEAR(degBetween(base, at10), 10.0f, 1.5f); // net 10°, not 30° Ogre::Entity* ref = makeArmRigEntity("armspace_idem_ref"); ASSERT_NE(ref, nullptr); @@ -1087,12 +1083,8 @@ TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) // Back to 0 restores the original pose bit-near-exactly. AnimationMerger::adjustArmSpace(skel, "clip", 0.0f); EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 0.0f); - const Ogre::Vector3 restored = armWorldDir(skel, "RightArm", "RightForeArm"); - fprintf(stderr, "[armspace-dbg] base=(%.3f,%.3f,%.3f) at10=(%.3f,%.3f,%.3f) " - "ref10=(%.3f,%.3f,%.3f) restored=(%.3f,%.3f,%.3f)\n", - base.x, base.y, base.z, at10.x, at10.y, at10.z, - ref10.x, ref10.y, ref10.z, restored.x, restored.y, restored.z); - EXPECT_GT(base.dotProduct(restored), 0.99999f); + EXPECT_GT(base.dotProduct(armWorldDir(skel, "RightArm", "RightForeArm")), + 0.9999f); } TEST_F(AnimationMergerTest, ArmSpaceLeavesNonArmBonesUntouched) From 68af9a8180ffa176412fa98ec26cd13a80d62764 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 01:08:52 -0400 Subject: [PATCH 08/16] docs: note the keyframe-edit cache-invalidation gotcha (#854) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f8e445bd..916efb67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -376,7 +376,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **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 --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`). - **MotionLibrary / text-to-motion** (`src/MotionLibrary.h/cpp`, issue #411, experimental): generate a skeletal animation from a text prompt. **The #411 spike (see `docs/TEXT_TO_MOTION_SPIKE_411.md`) proved a from-scratch GENERATIVE model (MDM-style) collapses to a static pose without multi-day ML effort, and all off-the-shelf models (MDM/T2M-GPT/MotionGPT) train on AMASS-derived HumanML3D/KIT-ML = non-commercial (the LAFAN1/ShapeNet wall again).** So the SHIPPED feature is a **template-clip MVP**: a curated library of permissive **CMU MoCap** clips (commercial-OK, same source as #409 RMIB), matched to the prompt by action keyword + synonyms (`MotionLibrary::matchPrompt`), then retargeted onto the user's rig via `AnimationMerger::applyMotionClip` → `MotionInbetween::canonicalIndexForBone` (the SAME 22-joint canonical mapping as #409). `MotionLibrary` is Ogre-free + unit-tested (`MotionLibrary_test.cpp`): parses `qtmesh-motion-library-v1`/`v2` JSON (per-frame, per-joint canonical quats; v2 adds an optional 22-entry `cmuRestWorld` block) + keyword matching. Library downloads on first use to `AppData/ai_models/motion/` (override `QTMESH_MOTION_LIBRARY_BASE_URL` / `QSettings ai/motionLibraryBaseUrl`; offline guard `QTMESH_MOTION_NO_DOWNLOAD`) — built offline by `scripts/build-motion-library.py` (10 actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle, ~0.9 MB), hosted on the [`fernandotonon/QtMeshEditor-models`](https://huggingface.co/fernandotonon/QtMeshEditor-models) HF repo under `motion/`. **Retarget (`applyMotionClip`) — the part that makes it look right:** the CMU clip stores each joint's LOCAL (parent-relative) rotation with rest ≈ identity (the rest DIRECTION is in the BVH bone offsets, captured as the v2 `cmuRestWorld` per-joint world-rest `Wcmu`). The exact per-bone formula is `local(f) = parentWorld⁻¹ · (Wcmu · clip(f) · Wcmu⁻¹) · parentWorld · bind`, where `bind` = the rig's STANDING pose harvested from frame-0 of its existing animation (Mixamo bone rest is identity — the standing pose lives in the anim, NOT in `getInitial{Orientation,Position}` which is inflated and would stretch the mesh), and the root (hip) is locked to the standing pose (CMU bakes whole-body facing into the root). The `Wcmu·clip·Wcmu⁻¹` conjugation is the CMU↔target change-of-basis that cancels the per-bone ROLL twist between rigs with different bone axes (Mixamo arms point down their length / sideways; UniRig is axis-aligned). v1 libraries (no `cmuRestWorld`) fall back to `Wcmu`=identity (parent-world transport only — direction-correct, residual roll). Writes rotation-only keyframes (translation/scale stay at the standing pose, preserving rig proportions) and requires ≥½ of the 22 canonical roles to resolve (else fails — not a humanoid rig). Render-verified on the Rumba (Mixamo) rig via the isometric loop: walk = upright stride with arms hanging+swinging; wave = upright + natural. Surfaced via **CLI `qtmesh anim --generate "" [--duration N] [-o out]`** (`CLIPipeline::cmdAnimGenerate`), the **MCP `generate_motion` tool** (`MCPServer::toolGenerateMotion`, args `{prompt, entity_name?, duration?, output_path?}`, registered heavy), and the **Animation panel "Generate from text" control** (`qml/AnimationControlPanel.qml` → `AnimationControlController::generateMotion`, emits `generateMotionStatus`). Sentry breadcrumb `ai.assist.text_to_motion`. **v4 library** (July 2026): clips are the trial's ACTIVE window (max motion energy, start snapped to a calm near-neutral frame — the retarget deltas against clip frame 0), replacing first-4s slices that mostly captured idle lead-ins; 13 actions (adds sit/throw/boxing; 'idle' now a real wait trial — the old 69_01 source walked; 'dance' is salsa — ballet pirouettes fold under the locked root). **Generative path (opt-in `--model` / `model:true` / GUI checkbox): `MotionGenerator` + `motion/t2m.onnx`** — a CVAE transformer trained from scratch on the same CMU source (`scripts/prep-t2m-v4.py` + `scripts/train-t2m-onnx-v4.py`, offline): 30fps WORLD-frame windows w/ neutral starts (the v3 model trained on raw-120fps 0.33s local-frame windows and folded/flailed at 3.5x real velocity), absolute-pose decoder (no error-accumulating delta-cumsum), per-sample + rotation-space (geodesic) velocity matching, derived-local supervision (parent^-1*child — the exact quantity applyMotionClip renders; world-only losses let spine-chain errors stack into a visible fold), and z=0 latent-dropout supervision (the app infers with seed=zeros; an unsupervised z=0 is out-of-distribution for a low-beta CVAE). The vocab json declares `frame:world` + `fps`, read by `MotionGenerator::generate` → `Result::worldFrame` → applyMotionClip, so model clips ride the same world retarget as v3 template clips. Template library stays the default + automatic fallback. **Quality limit:** the model's z=0 output is smooth/upright but gentler than real clips (conditional-mean effect; the medoid-exemplar alternative is crisper numerically but renders twisted — `--z0-target` flag documents both); the template path is the quality bar. -- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on ANY animation (not just generated ones) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Absolute + idempotent**: a **session-local** `std::map<(skeletonName,animName),float>` tracks the last-applied angle (NOT UserObjectBindings — `Ogre::Animation` has none; NOT persisted — export bakes the final keyframes), and each call reverts it first (delta = new−stored). So the angle is ABSOLUTE: `+45` leaves the clip at +45, a later `−45` leaves it at −45 (net = −45), and only `adjustArmSpace(0)` restores the base pose bit-near-exactly. `currentArmSpace(skel, anim)` exposes the stored value so the GUI seeds its slider with the clip's real state. `applyMotionClip` erases the entry when it (re)creates a `generated_*` clip so a regenerated clip starts unadjusted. `migrateArmSpaceKey` moves the entry on rename — called from BOTH `AnimationMerger::renameAnimation` (CLI/merge) and `SkeletonTransform::renameAnimation` (GUI) so a renamed widened clip keeps its value. Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` (response echoes `arm_space_applied`) + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace` — edits the mesh's MASTER skeleton so `output_path` export includes the change), and the **Inspector Animations section**: a live "Arm space" slider (−30…+45°) plus a per-row `↔` button that targets any clip (`qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`/`currentArmSpace`). The slider updates the viewport LIVE while dragging, even when the clip is PAUSED (`_notifyDirty` + re-stamp the state's time); generation never bakes the slider value in. Unit-tested in `AnimationMerger_test.cpp` (swing angle, mirrored per-side direction, absolute/idempotent via `currentArmSpace`, non-arm invariance, rename migration). Sentry breadcrumbs: `ui.action` (GUI) / `ai.tool_call` (CLI + MCP). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. +- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on ANY animation (not just generated ones) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Gotcha:** `TransformKeyFrame::setRotation` does NOT invalidate the track's interpolation caches, so after editing keyframes you MUST call `track->_keyFrameDataChanged()` or the next `apply()` replays the pre-edit rotations (edits appear to lag one call — masked in the live GUI by the render loop's continuous re-apply, but deterministic single evaluations get the stale pose). **Absolute + idempotent**: a **session-local** `std::map<(skeletonName,animName),float>` tracks the last-applied angle (NOT UserObjectBindings — `Ogre::Animation` has none; NOT persisted — export bakes the final keyframes), and each call reverts it first (delta = new−stored). So the angle is ABSOLUTE: `+45` leaves the clip at +45, a later `−45` leaves it at −45 (net = −45), and only `adjustArmSpace(0)` restores the base pose bit-near-exactly. `currentArmSpace(skel, anim)` exposes the stored value so the GUI seeds its slider with the clip's real state. `applyMotionClip` erases the entry when it (re)creates a `generated_*` clip so a regenerated clip starts unadjusted. `migrateArmSpaceKey` moves the entry on rename — called from BOTH `AnimationMerger::renameAnimation` (CLI/merge) and `SkeletonTransform::renameAnimation` (GUI) so a renamed widened clip keeps its value. Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` (response echoes `arm_space_applied`) + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace` — edits the mesh's MASTER skeleton so `output_path` export includes the change), and the **Inspector Animations section**: a live "Arm space" slider (−30…+45°) plus a per-row `↔` button that targets any clip (`qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`/`currentArmSpace`). The slider updates the viewport LIVE while dragging, even when the clip is PAUSED (`_notifyDirty` + re-stamp the state's time); generation never bakes the slider value in. Unit-tested in `AnimationMerger_test.cpp` (swing angle, mirrored per-side direction, absolute/idempotent via `currentArmSpace`, non-arm invariance, rename migration). Sentry breadcrumbs: `ui.action` (GUI) / `ai.tool_call` (CLI + MCP). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. - **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. From baa575a77f23d5abdfa8773eb77b5700af3fc665 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 01:46:34 -0400 Subject: [PATCH 09/16] test(anim): reset skeleton before measuring arm-space pose (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arm-space test failures were a MEASUREMENT artifact, not a production bug: Ogre's Animation::apply accumulates onto the current pose (node-> rotate, not set), and the armWorldDir helper called apply without resetting first — so each measurement compounded the previous apply and the pose appeared to lag one call behind (20 then 10 read as 20°, →0 read as 30°). Resetting to bind before apply makes every measurement independent; the absolute/idempotent contract now verifies exactly (20 then 10 == 10, →0 == base, mirrored per-side swing). The core adjustArmSpace was correct throughout — the earlier _keyFrameDataChanged() call is kept as correct hygiene for direct keyframe edits (documented in CLAUDE.md) but was not the cause. Removed the debug traces. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AnimationMerger_test.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 7d5ecf09..e4f8a2ac 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -136,10 +136,14 @@ class AnimationMergerTest : public ::testing::Test { } // World direction of a bone toward its child, at t=0.5 of `anim`. + // NB: Ogre's Animation::apply ACCUMULATES onto the current pose (it calls + // node->rotate, not set), so reset to bind first — otherwise each + // measurement compounds the previous apply and the pose appears to lag. static Ogre::Vector3 armWorldDir(Ogre::SkeletonInstance* skel, const char* boneName, const char* childName, const char* anim = "clip") { + skel->reset(true); skel->getAnimation(anim)->apply(skel, 0.5f); skel->_updateTransforms(); const Ogre::Vector3 a = skel->getBone(boneName)->_getDerivedPosition(); From 2fffd37dbbeeb8d06f49767e3ac503d53434e806 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 02:47:27 -0400 Subject: [PATCH 10/16] test(anim): trace idempotence vectors (#854, debug) --- src/AnimationMerger_test.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index e4f8a2ac..2dea38fc 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1087,8 +1087,15 @@ TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) // Back to 0 restores the original pose bit-near-exactly. AnimationMerger::adjustArmSpace(skel, "clip", 0.0f); EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 0.0f); - EXPECT_GT(base.dotProduct(armWorldDir(skel, "RightArm", "RightForeArm")), - 0.9999f); + const Ogre::Vector3 restored = armWorldDir(skel, "RightArm", "RightForeArm"); + fprintf(stderr, "[armspace-dbg3] base=(%.3f,%.3f,%.3f) at10=(%.3f,%.3f,%.3f) " + "deg(base,at10)=%.1f ref10=(%.3f,%.3f,%.3f) deg(base,ref10)=%.1f " + "restored=(%.3f,%.3f,%.3f) deg(base,restored)=%.1f\n", + base.x, base.y, base.z, at10.x, at10.y, at10.z, + degBetween(base, at10), ref10.x, ref10.y, ref10.z, + degBetween(base, ref10), restored.x, restored.y, restored.z, + degBetween(base, restored)); + EXPECT_GT(base.dotProduct(restored), 0.9999f); } TEST_F(AnimationMergerTest, ArmSpaceLeavesNonArmBonesUntouched) From 50a5a15f0bb5457d990323499f9af325c6de50f5 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 07:07:25 -0400 Subject: [PATCH 11/16] test(anim): in-function arm-space delta+pose trace (#854, debug) --- src/AnimationMerger.cpp | 20 ++++++++++++++++++++ src/AnimationMerger_test.cpp | 1 + 2 files changed, 21 insertions(+) diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index 428e75ee..096271f5 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -1453,6 +1453,26 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, return false; // no arm role on this rig g_armSpaceApplied[key] = degrees; + + if (!qEnvironmentVariableIsEmpty("QTMESH_ARMSPACE_DEBUG")) { + // Measure the right-shoulder world direction right here, so we can + // tell an apply/compose bug from a downstream measurement artifact. + const int si = tb.roleBoneIdx[7]; + const int ci = tb.roleBoneIdx[8]; + if (si >= 0 && ci >= 0) { + skel->reset(true); + anim->apply(skel, anim->getLength() * 0.5f); + skel->_updateTransforms(); + Ogre::Vector3 d = + skel->getBone(static_cast(ci))->_getDerivedPosition() + - skel->getBone(static_cast(si))->_getDerivedPosition(); + d.normalise(); + fprintf(stderr, "[armspace-core] degrees=%.1f stored_before=%.1f " + "delta=%.1f -> Rdir=(%.3f,%.3f,%.3f)\n", + degrees, stored, delta, d.x, d.y, d.z); + fflush(stderr); + } + } return true; } diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 2dea38fc..4bb368d6 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1065,6 +1065,7 @@ TEST_F(AnimationMergerTest, ArmSpaceWidensAndTucksArms) TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) { + qputenv("QTMESH_ARMSPACE_DEBUG", "1"); Ogre::Entity* ent = makeArmRigEntity("armspace_idem"); ASSERT_NE(ent, nullptr); Ogre::SkeletonInstance* skel = ent->getSkeleton(); From 32424b5def27bc739edc4388e131adfe7501a482 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 08:54:40 -0400 Subject: [PATCH 12/16] fix(anim): store arm-space per skeleton instance, not a global map (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the persistent unit-test failure: g_armSpaceApplied was a process-global static keyed by (skeletonName, animName). CI runs each gtest suite in ONE process (--gtest_filter="AnimationMergerTest.*"), so state leaked between tests — the idempotence test's first call already saw stored=30 from the earlier WidensAndTucks test, and the per-call trace confirmed every delta was computed against a polluted baseline (applied angle = sum of all-but-last call). This is the same fragility CodeRabbit flagged as "not instance-safe". Fix: track the applied angle on the skeleton's bone[0] UserObjectBindings under a per-animation key ("qtme.armspace."). It now lives and dies with the skeleton instance — isolated across entities AND tests, no global state. getStoredArmSpace/setStoredArmSpace helpers; currentArmSpace, adjustArmSpace, the regenerate-clear in applyMotionClip, and migrateArmSpaceKey (now takes Ogre::Skeleton*) all go through it. The core swing math was correct all along (the in-function pose trace tracked keyframes exactly); the bug was purely the shared-state baseline. Removed all debug traces. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AnimationMerger.cpp | 97 +++++++++++++++++------------------- src/AnimationMerger.h | 2 +- src/AnimationMerger_test.cpp | 12 +---- src/SkeletonTransform.cpp | 2 +- 4 files changed, 51 insertions(+), 62 deletions(-) diff --git a/src/AnimationMerger.cpp b/src/AnimationMerger.cpp index 096271f5..921e2656 100644 --- a/src/AnimationMerger.cpp +++ b/src/AnimationMerger.cpp @@ -21,11 +21,31 @@ // Populated by AnimationMerger::registerSkeletonUpAxis() at import time. static QMap s_skeletonUpAxis; -// #854: last-applied arm-space angle per (skeleton, animation), session -// scoped. adjustArmSpace() reads it to make each apply absolute+idempotent; -// currentArmSpace() exposes it so a UI can seed the slider with the clip's -// real value (never persisted — export bakes the keyframes). -static std::map, float> g_armSpaceApplied; +// #854: last-applied arm-space angle, tracked PER SKELETON INSTANCE (not a +// process-global map — that pollutes across entities and across tests that +// share a process). Stored on the skeleton's first bone via UserObjectBindings +// under a per-animation key, so it lives and dies with the skeleton and is +// naturally isolated. Session-scoped only; export bakes the final keyframes. +namespace { +std::string armSpaceKey(const std::string& animName) +{ + return "qtme.armspace." + animName; +} +float getStoredArmSpace(Ogre::Skeleton* skel, const std::string& animName) +{ + if (!skel || skel->getNumBones() == 0) return 0.0f; + const Ogre::Any& a = skel->getBone(0)->getUserObjectBindings() + .getUserAny(armSpaceKey(animName)); + return a.has_value() ? Ogre::any_cast(a) : 0.0f; +} +void setStoredArmSpace(Ogre::Skeleton* skel, const std::string& animName, + float degrees) +{ + if (!skel || skel->getNumBones() == 0) return; + skel->getBone(0)->getUserObjectBindings().setUserAny( + armSpaceKey(animName), Ogre::Any(degrees)); +} +} // namespace void AnimationMerger::registerSkeletonUpAxis(const std::string& name, int upAxis) { s_skeletonUpAxis[QString::fromStdString(name)] = upAxis; @@ -243,21 +263,21 @@ void AnimationMerger::renameAnimation(Ogre::Skeleton* skel, // widened keyframes were copied above, so the tracked angle must follow // or currentArmSpace() would report 0 for the renamed clip and the next // slider drag would compute a wrong (absolute-from-0) delta. - migrateArmSpaceKey(skel->getName(), oldName, newName); + migrateArmSpaceKey(skel, oldName, newName); skel->removeAnimation(oldName); } -void AnimationMerger::migrateArmSpaceKey(const std::string& skeletonName, +void AnimationMerger::migrateArmSpaceKey(Ogre::Skeleton* skel, const std::string& oldAnim, const std::string& newAnim) { - if (oldAnim == newAnim) return; - const std::pair oldKey(skeletonName, oldAnim); - if (auto it = g_armSpaceApplied.find(oldKey); - it != g_armSpaceApplied.end()) { - g_armSpaceApplied[{skeletonName, newAnim}] = it->second; - g_armSpaceApplied.erase(it); + if (!skel || oldAnim == newAnim || skel->getNumBones() == 0) return; + auto& uob = skel->getBone(0)->getUserObjectBindings(); + const Ogre::Any& a = uob.getUserAny(armSpaceKey(oldAnim)); + if (a.has_value()) { + setStoredArmSpace(skel, newAnim, Ogre::any_cast(a)); + uob.eraseUserAny(armSpaceKey(oldAnim)); } } @@ -1328,9 +1348,7 @@ TargetBindFrame readTargetBindFrame(Ogre::Skeleton* skel, float AnimationMerger::currentArmSpace(Ogre::Skeleton* skel, const std::string& animName) { - if (!skel) return 0.0f; - const auto it = g_armSpaceApplied.find({skel->getName(), animName}); - return it != g_armSpaceApplied.end() ? it->second : 0.0f; + return getStoredArmSpace(skel, animName); } bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, @@ -1345,16 +1363,12 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, // Idempotent absolute application: revert whatever we applied before, // then apply the new absolute angle. The net delta this call injects is - // (degrees − stored). The last-applied angle per (skeleton, animation) is - // tracked in the file-scoped g_armSpaceApplied map (Ogre::Animation has no - // UserObjectBindings) — which is exactly what slider scrubbing needs, and - // currentArmSpace() exposes it so a UI can seed the slider with the clip's - // real value. Export bakes the final keyframes, so cross-session - // persistence isn't required. Zeroing restores the clip bit-near-exactly. - const std::pair key(skel->getName(), animName); - float stored = 0.0f; - if (auto it = g_armSpaceApplied.find(key); it != g_armSpaceApplied.end()) - stored = it->second; + // (degrees − stored). The last-applied angle is tracked PER SKELETON + // INSTANCE (on bone[0]'s UserObjectBindings, keyed by animation) — not a + // process-global map, which would pollute across entities and tests. + // currentArmSpace() exposes it so a UI can seed the slider. Export bakes + // the final keyframes, so cross-session persistence isn't required. + const float stored = getStoredArmSpace(skel, animName); const float delta = degrees - stored; if (std::abs(delta) < 1e-4f) return true; // already at target — nothing to do (still success) @@ -1452,27 +1466,7 @@ bool AnimationMerger::adjustArmSpace(Ogre::Skeleton* skel, if (!touchedAny) return false; // no arm role on this rig - g_armSpaceApplied[key] = degrees; - - if (!qEnvironmentVariableIsEmpty("QTMESH_ARMSPACE_DEBUG")) { - // Measure the right-shoulder world direction right here, so we can - // tell an apply/compose bug from a downstream measurement artifact. - const int si = tb.roleBoneIdx[7]; - const int ci = tb.roleBoneIdx[8]; - if (si >= 0 && ci >= 0) { - skel->reset(true); - anim->apply(skel, anim->getLength() * 0.5f); - skel->_updateTransforms(); - Ogre::Vector3 d = - skel->getBone(static_cast(ci))->_getDerivedPosition() - - skel->getBone(static_cast(si))->_getDerivedPosition(); - d.normalise(); - fprintf(stderr, "[armspace-core] degrees=%.1f stored_before=%.1f " - "delta=%.1f -> Rdir=(%.3f,%.3f,%.3f)\n", - degrees, stored, delta, d.x, d.y, d.z); - fflush(stderr); - } - } + setStoredArmSpace(skel, animName, degrees); return true; } @@ -1573,9 +1567,12 @@ AnimationMerger::ApplyMotionResult AnimationMerger::applyMotionClip( if (skel->hasAnimation(animName)) skel->removeAnimation(animName); // #854: the freshly (re)created clip has NO arm-space applied. A stale - // entry from a prior generation of the same name would make a re-request - // of that same angle a no-op (delta 0) on the new keyframes — forget it. - g_armSpaceApplied.erase({skel->getName(), animName}); + // stored angle from a prior generation of the same name would make a + // re-request of that same angle a no-op (delta 0) on the new keyframes — + // forget it. + if (skel->getNumBones() > 0) + skel->getBone(0)->getUserObjectBindings().eraseUserAny( + armSpaceKey(animName)); Ogre::Animation* anim = skel->createAnimation(animName, length); anim->setInterpolationMode(Ogre::Animation::IM_LINEAR); anim->setRotationInterpolationMode(Ogre::Animation::RIM_LINEAR); diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index 10151f20..ad3fe0d0 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -257,7 +257,7 @@ class AnimationMerger { /// its widen/tuck value (the keyframes carry over; the tracked angle must /// too, or the next slider drag mis-computes its delta). No-op if there was /// no tracked angle or the names match. - static void migrateArmSpaceKey(const std::string& skeletonName, + static void migrateArmSpaceKey(Ogre::Skeleton* skel, const std::string& oldAnim, const std::string& newAnim); diff --git a/src/AnimationMerger_test.cpp b/src/AnimationMerger_test.cpp index 4bb368d6..e4f8a2ac 100644 --- a/src/AnimationMerger_test.cpp +++ b/src/AnimationMerger_test.cpp @@ -1065,7 +1065,6 @@ TEST_F(AnimationMergerTest, ArmSpaceWidensAndTucksArms) TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) { - qputenv("QTMESH_ARMSPACE_DEBUG", "1"); Ogre::Entity* ent = makeArmRigEntity("armspace_idem"); ASSERT_NE(ent, nullptr); Ogre::SkeletonInstance* skel = ent->getSkeleton(); @@ -1088,15 +1087,8 @@ TEST_F(AnimationMergerTest, ArmSpaceIsIdempotentAndAbsolute) // Back to 0 restores the original pose bit-near-exactly. AnimationMerger::adjustArmSpace(skel, "clip", 0.0f); EXPECT_FLOAT_EQ(AnimationMerger::currentArmSpace(skel, "clip"), 0.0f); - const Ogre::Vector3 restored = armWorldDir(skel, "RightArm", "RightForeArm"); - fprintf(stderr, "[armspace-dbg3] base=(%.3f,%.3f,%.3f) at10=(%.3f,%.3f,%.3f) " - "deg(base,at10)=%.1f ref10=(%.3f,%.3f,%.3f) deg(base,ref10)=%.1f " - "restored=(%.3f,%.3f,%.3f) deg(base,restored)=%.1f\n", - base.x, base.y, base.z, at10.x, at10.y, at10.z, - degBetween(base, at10), ref10.x, ref10.y, ref10.z, - degBetween(base, ref10), restored.x, restored.y, restored.z, - degBetween(base, restored)); - EXPECT_GT(base.dotProduct(restored), 0.9999f); + EXPECT_GT(base.dotProduct(armWorldDir(skel, "RightArm", "RightForeArm")), + 0.9999f); } TEST_F(AnimationMergerTest, ArmSpaceLeavesNonArmBonesUntouched) diff --git a/src/SkeletonTransform.cpp b/src/SkeletonTransform.cpp index cf095cc3..18df062e 100755 --- a/src/SkeletonTransform.cpp +++ b/src/SkeletonTransform.cpp @@ -161,7 +161,7 @@ bool SkeletonTransform::renameAnimation(Ogre::Entity *_ent, const QString &_oldN // #854: keep the arm-space widen/tuck value tied to the renamed clip // (the keyframes were copied above; migrate the tracked angle too). AnimationMerger::migrateArmSpaceKey( - sk->getName(), _oldName.toStdString(), _newName.toStdString()); + sk, _oldName.toStdString(), _newName.toStdString()); //Remove the old animation sk->removeAnimation(_oldName.toStdString()); From dd00dbccd4dc0f615c336caa3f16b9a1dda6b6da Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 08:55:24 -0400 Subject: [PATCH 13/16] docs(anim): arm-space storage is per-instance UserObjectBindings (#854) --- CLAUDE.md | 2 +- src/AnimationMerger.h | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 916efb67..b9431ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -376,7 +376,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **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 --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`). - **MotionLibrary / text-to-motion** (`src/MotionLibrary.h/cpp`, issue #411, experimental): generate a skeletal animation from a text prompt. **The #411 spike (see `docs/TEXT_TO_MOTION_SPIKE_411.md`) proved a from-scratch GENERATIVE model (MDM-style) collapses to a static pose without multi-day ML effort, and all off-the-shelf models (MDM/T2M-GPT/MotionGPT) train on AMASS-derived HumanML3D/KIT-ML = non-commercial (the LAFAN1/ShapeNet wall again).** So the SHIPPED feature is a **template-clip MVP**: a curated library of permissive **CMU MoCap** clips (commercial-OK, same source as #409 RMIB), matched to the prompt by action keyword + synonyms (`MotionLibrary::matchPrompt`), then retargeted onto the user's rig via `AnimationMerger::applyMotionClip` → `MotionInbetween::canonicalIndexForBone` (the SAME 22-joint canonical mapping as #409). `MotionLibrary` is Ogre-free + unit-tested (`MotionLibrary_test.cpp`): parses `qtmesh-motion-library-v1`/`v2` JSON (per-frame, per-joint canonical quats; v2 adds an optional 22-entry `cmuRestWorld` block) + keyword matching. Library downloads on first use to `AppData/ai_models/motion/` (override `QTMESH_MOTION_LIBRARY_BASE_URL` / `QSettings ai/motionLibraryBaseUrl`; offline guard `QTMESH_MOTION_NO_DOWNLOAD`) — built offline by `scripts/build-motion-library.py` (10 actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle, ~0.9 MB), hosted on the [`fernandotonon/QtMeshEditor-models`](https://huggingface.co/fernandotonon/QtMeshEditor-models) HF repo under `motion/`. **Retarget (`applyMotionClip`) — the part that makes it look right:** the CMU clip stores each joint's LOCAL (parent-relative) rotation with rest ≈ identity (the rest DIRECTION is in the BVH bone offsets, captured as the v2 `cmuRestWorld` per-joint world-rest `Wcmu`). The exact per-bone formula is `local(f) = parentWorld⁻¹ · (Wcmu · clip(f) · Wcmu⁻¹) · parentWorld · bind`, where `bind` = the rig's STANDING pose harvested from frame-0 of its existing animation (Mixamo bone rest is identity — the standing pose lives in the anim, NOT in `getInitial{Orientation,Position}` which is inflated and would stretch the mesh), and the root (hip) is locked to the standing pose (CMU bakes whole-body facing into the root). The `Wcmu·clip·Wcmu⁻¹` conjugation is the CMU↔target change-of-basis that cancels the per-bone ROLL twist between rigs with different bone axes (Mixamo arms point down their length / sideways; UniRig is axis-aligned). v1 libraries (no `cmuRestWorld`) fall back to `Wcmu`=identity (parent-world transport only — direction-correct, residual roll). Writes rotation-only keyframes (translation/scale stay at the standing pose, preserving rig proportions) and requires ≥½ of the 22 canonical roles to resolve (else fails — not a humanoid rig). Render-verified on the Rumba (Mixamo) rig via the isometric loop: walk = upright stride with arms hanging+swinging; wave = upright + natural. Surfaced via **CLI `qtmesh anim --generate "" [--duration N] [-o out]`** (`CLIPipeline::cmdAnimGenerate`), the **MCP `generate_motion` tool** (`MCPServer::toolGenerateMotion`, args `{prompt, entity_name?, duration?, output_path?}`, registered heavy), and the **Animation panel "Generate from text" control** (`qml/AnimationControlPanel.qml` → `AnimationControlController::generateMotion`, emits `generateMotionStatus`). Sentry breadcrumb `ai.assist.text_to_motion`. **v4 library** (July 2026): clips are the trial's ACTIVE window (max motion energy, start snapped to a calm near-neutral frame — the retarget deltas against clip frame 0), replacing first-4s slices that mostly captured idle lead-ins; 13 actions (adds sit/throw/boxing; 'idle' now a real wait trial — the old 69_01 source walked; 'dance' is salsa — ballet pirouettes fold under the locked root). **Generative path (opt-in `--model` / `model:true` / GUI checkbox): `MotionGenerator` + `motion/t2m.onnx`** — a CVAE transformer trained from scratch on the same CMU source (`scripts/prep-t2m-v4.py` + `scripts/train-t2m-onnx-v4.py`, offline): 30fps WORLD-frame windows w/ neutral starts (the v3 model trained on raw-120fps 0.33s local-frame windows and folded/flailed at 3.5x real velocity), absolute-pose decoder (no error-accumulating delta-cumsum), per-sample + rotation-space (geodesic) velocity matching, derived-local supervision (parent^-1*child — the exact quantity applyMotionClip renders; world-only losses let spine-chain errors stack into a visible fold), and z=0 latent-dropout supervision (the app infers with seed=zeros; an unsupervised z=0 is out-of-distribution for a low-beta CVAE). The vocab json declares `frame:world` + `fps`, read by `MotionGenerator::generate` → `Result::worldFrame` → applyMotionClip, so model clips ride the same world retarget as v3 template clips. Template library stays the default + automatic fallback. **Quality limit:** the model's z=0 output is smooth/upright but gentler than real clips (conditional-mean effect; the medoid-exemplar alternative is crisper numerically but renders twisted — `--z0-target` flag documents both); the template path is the quality bar. -- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on ANY animation (not just generated ones) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Gotcha:** `TransformKeyFrame::setRotation` does NOT invalidate the track's interpolation caches, so after editing keyframes you MUST call `track->_keyFrameDataChanged()` or the next `apply()` replays the pre-edit rotations (edits appear to lag one call — masked in the live GUI by the render loop's continuous re-apply, but deterministic single evaluations get the stale pose). **Absolute + idempotent**: a **session-local** `std::map<(skeletonName,animName),float>` tracks the last-applied angle (NOT UserObjectBindings — `Ogre::Animation` has none; NOT persisted — export bakes the final keyframes), and each call reverts it first (delta = new−stored). So the angle is ABSOLUTE: `+45` leaves the clip at +45, a later `−45` leaves it at −45 (net = −45), and only `adjustArmSpace(0)` restores the base pose bit-near-exactly. `currentArmSpace(skel, anim)` exposes the stored value so the GUI seeds its slider with the clip's real state. `applyMotionClip` erases the entry when it (re)creates a `generated_*` clip so a regenerated clip starts unadjusted. `migrateArmSpaceKey` moves the entry on rename — called from BOTH `AnimationMerger::renameAnimation` (CLI/merge) and `SkeletonTransform::renameAnimation` (GUI) so a renamed widened clip keeps its value. Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` (response echoes `arm_space_applied`) + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace` — edits the mesh's MASTER skeleton so `output_path` export includes the change), and the **Inspector Animations section**: a live "Arm space" slider (−30…+45°) plus a per-row `↔` button that targets any clip (`qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`/`currentArmSpace`). The slider updates the viewport LIVE while dragging, even when the clip is PAUSED (`_notifyDirty` + re-stamp the state's time); generation never bakes the slider value in. Unit-tested in `AnimationMerger_test.cpp` (swing angle, mirrored per-side direction, absolute/idempotent via `currentArmSpace`, non-arm invariance, rename migration). Sentry breadcrumbs: `ui.action` (GUI) / `ai.tool_call` (CLI + MCP). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. +- **Arm-space post-process** (`AnimationMerger::adjustArmSpace`, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on ANY animation (not just generated ones) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame `Ct`, reusing the retarget's `readTargetBindFrame` helper), mirrored per side so `+deg` widens both arms. Keyframes are deltas on the bind pose (Ogre's `NodeAnimationTrack::applyToNode` post-multiplies onto the reset bone), so the world swing `S` is folded into each keyframe as `L·kf` where `L = Wbind⁻¹·S·Wbind` (conjugation into the bone's bind-local frame). **Gotcha:** `TransformKeyFrame::setRotation` does NOT invalidate the track's interpolation caches, so after editing keyframes you MUST call `track->_keyFrameDataChanged()` or the next `apply()` replays the pre-edit rotations (edits appear to lag one call — masked in the live GUI by the render loop's continuous re-apply, but deterministic single evaluations get the stale pose). **Absolute + idempotent**: the last-applied angle is tracked PER SKELETON INSTANCE on `bone[0]`'s UserObjectBindings (key `qtme.armspace.`) — NOT a process-global map (that pollutes across entities AND across tests sharing a process, which is exactly how the first cut regressed); NOT persisted to disk (export bakes the final keyframes). Each call reverts the stored angle first (delta = new−stored). So the angle is ABSOLUTE: `+45` leaves the clip at +45, a later `−45` leaves it at −45 (net = −45), and only `adjustArmSpace(0)` restores the base pose bit-near-exactly. `currentArmSpace(skel, anim)` exposes the stored value so the GUI seeds its slider with the clip's real state. `applyMotionClip` erases the entry when it (re)creates a `generated_*` clip so a regenerated clip starts unadjusted. `migrateArmSpaceKey` moves the entry on rename — called from BOTH `AnimationMerger::renameAnimation` (CLI/merge) and `SkeletonTransform::renameAnimation` (GUI) so a renamed widened clip keeps its value. Surfaced via **CLI** `qtmesh anim --generate "

" --arm-space ` and standalone `qtmesh anim --arm-space --animation -o out` (`CLIPipeline::cmdAnim`/`cmdAnimGenerate`), **MCP** `arm_space` arg on `generate_motion` (response echoes `arm_space_applied`) + standalone `adjust_arm_space` tool (`MCPServer::toolAdjustArmSpace` — edits the mesh's MASTER skeleton so `output_path` export includes the change), and the **Inspector Animations section**: a live "Arm space" slider (−30…+45°) plus a per-row `↔` button that targets any clip (`qml/PropertiesPanel.qml` → `AnimationControlController::adjustArmSpace`/`currentArmSpace`). The slider updates the viewport LIVE while dragging, even when the clip is PAUSED (`_notifyDirty` + re-stamp the state's time); generation never bakes the slider value in. Unit-tested in `AnimationMerger_test.cpp` (swing angle, mirrored per-side direction, absolute/idempotent via `currentArmSpace`, non-arm invariance, rename migration). Sentry breadcrumbs: `ui.action` (GUI) / `ai.tool_call` (CLI + MCP). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs. - **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. diff --git a/src/AnimationMerger.h b/src/AnimationMerger.h index ad3fe0d0..543e72be 100644 --- a/src/AnimationMerger.h +++ b/src/AnimationMerger.h @@ -228,20 +228,19 @@ class AnimationMerger { /// over-rotate. /// /// ABSOLUTE + IDEMPOTENT: `degrees` is the target angle, not a nudge. The - /// last-applied angle is tracked in a SESSION-LOCAL static map keyed by - /// (skeleton name, animation name) — Ogre::Animation has no - /// UserObjectBindings, and the value is NOT persisted (export bakes the + /// last-applied angle is tracked PER SKELETON INSTANCE on bone[0]'s + /// UserObjectBindings (key "qtme.armspace.") — isolated per entity, + /// never a process-global, and NOT persisted to disk (export bakes the /// final keyframes). Each call reverts the stored angle before applying /// the new one (delta = new − stored), so `adjustArmSpace(20)` then /// `adjustArmSpace(10)` == `adjustArmSpace(10)` from the original, and - /// `adjustArmSpace(0)` restores the clip bit-near-exactly. NB: because the - /// map is session-local, a fresh CLI process sees `stored == 0`, so - /// re-opening an already-exported widened clip and calling - /// `adjustArmSpace(0)` is a no-op (the keyframes are baked). Use - /// currentArmSpace() to read the tracked value; applyMotionClip clears it - /// when it regenerates a clip, and migrateArmSpaceKey() moves it on rename. - /// Returns false (no-op) if the animation is missing or no arm role - /// resolves on the rig. + /// `adjustArmSpace(0)` restores the clip bit-near-exactly. NB: the binding + /// is in-memory only, so a fresh CLI process (which loads the baked clip) + /// sees `stored == 0` — calling `adjustArmSpace(0)` there is a no-op. + /// Use currentArmSpace() to read the tracked value; applyMotionClip clears + /// it when it regenerates a clip, and migrateArmSpaceKey() moves it on + /// rename. Returns false (no-op) if the animation is missing or no arm + /// role resolves on the rig. static bool adjustArmSpace(Ogre::Skeleton* skel, const std::string& animName, float degrees); From 6fa8c1cd785c7739c08ccd0c5ee140b5e912f366 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 09:13:56 -0400 Subject: [PATCH 14/16] ci(macos): retry create-dmg + hdiutil fallback in the Pack step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS Pack step's create-dmg fails intermittently on CI (it mounts a disk image and AppleScript-positions icons; the mount/detach races) — seen blocking PRs #866 and #867 despite no code issue, passing on plain rerun. Retry up to 3× (detaching stray mounts between tries) and, if it still fails, fall back to a plain hdiutil UDZO DMG so a transient tooling flake never blocks the build. The pretty layout is cosmetic; the fallback DMG installs identically. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 54 ++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2dbbfbd..734ad1e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2138,19 +2138,49 @@ jobs: file ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor - name: Pack - run: | + run: | + set -euo pipefail brew install create-dmg - sudo create-dmg \ - --volname "QtMeshEditor Installer" \ - --volicon "${{github.workspace}}/resources/icon.icns" \ - --window-pos 200 120 \ - --window-size 800 400 \ - --icon-size 100 \ - --icon "QtMeshEditor.app" 200 190 \ - --app-drop-link 600 185 \ - QtMeshEditor-${{github.ref_name}}-MacOS.dmg \ - ${{github.workspace}}/bin/QtMeshEditor.app - + DMG="QtMeshEditor-${{github.ref_name}}-MacOS.dmg" + APP="${{github.workspace}}/bin/QtMeshEditor.app" + + # create-dmg mounts a disk image and AppleScript-positions the + # icons; on CI that mount/detach races and fails intermittently + # (seen on PRs #866/#867). Retry a few times, and if it still + # fails fall back to a plain hdiutil DMG so packaging never blocks + # the build on a transient tooling flake. The pretty layout is + # only cosmetic — the fallback DMG installs identically. + make_pretty_dmg() { + rm -f "$DMG" + sudo create-dmg \ + --volname "QtMeshEditor Installer" \ + --volicon "${{github.workspace}}/resources/icon.icns" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "QtMeshEditor.app" 200 190 \ + --app-drop-link 600 185 \ + "$DMG" "$APP" + } + ok=0 + for attempt in 1 2 3; do + echo "create-dmg attempt $attempt…" + if make_pretty_dmg; then ok=1; break; fi + echo "create-dmg attempt $attempt failed; detaching stray mounts and retrying" + hdiutil detach "/Volumes/QtMeshEditor Installer" -force 2>/dev/null || true + sleep 5 + done + if [ "$ok" -ne 1 ]; then + echo "create-dmg failed 3× — falling back to a plain hdiutil DMG" + rm -f "$DMG" + staging="$(mktemp -d)" + cp -R "$APP" "$staging/" + ln -s /Applications "$staging/Applications" + hdiutil create -volname "QtMeshEditor Installer" \ + -srcfolder "$staging" -ov -format UDZO "$DMG" + fi + ls -la "$DMG" + - if: github.event_name == 'release' && github.event.action == 'published' uses: actions/upload-artifact@v4 with: From 0e44ecfd0e9dd93a44818985cdc6c068e11a55d0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 09:32:50 -0400 Subject: [PATCH 15/16] ci(macos): fix unbound-variable in Pack retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardened Pack step failed with 'attempt: unbound variable' under set -u: the echo used a unicode ellipsis directly after $attempt ("attempt $attempt…"), and bash tried to expand the multi-byte character as part of the variable name. Brace the expansion (${attempt}) and use ASCII punctuation in the retry/fallback echoes. Verified with bash -n and a set -u loop check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 734ad1e5..00d578e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2164,14 +2164,14 @@ jobs: } ok=0 for attempt in 1 2 3; do - echo "create-dmg attempt $attempt…" + echo "create-dmg attempt ${attempt}..." if make_pretty_dmg; then ok=1; break; fi - echo "create-dmg attempt $attempt failed; detaching stray mounts and retrying" + echo "create-dmg attempt ${attempt} failed; detaching stray mounts and retrying" hdiutil detach "/Volumes/QtMeshEditor Installer" -force 2>/dev/null || true sleep 5 done if [ "$ok" -ne 1 ]; then - echo "create-dmg failed 3× — falling back to a plain hdiutil DMG" + echo "create-dmg failed 3x - falling back to a plain hdiutil DMG" rm -f "$DMG" staging="$(mktemp -d)" cp -R "$APP" "$staging/" From c3578073f22724d7ce054d1366b489b3b2912426 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 12 Jul 2026 09:50:58 -0400 Subject: [PATCH 16/16] =?UTF-8?q?ci(macos):=20sanitize=20slash=20in=20DMG?= =?UTF-8?q?=20name=20=E2=80=94=20the=20real=20Pack=20failure=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deterministic macOS Pack failure was github.ref_name being "867/merge" on a PR: the slash made create-dmg treat "QtMeshEditor-867/merge-MacOS.dmg" as a path, cd into a nonexistent "QtMeshEditor-867/" dir, and emit the DMG under the wrong name — so the step failed regardless of retries. (This, not a create-dmg mount flake, is why macOS failed on every PR run.) Fix: tr '/' '-' out of ref_name for the DMG basename. Release tags (X.Y.Z) are unaffected, so the artifact-upload/release steps still match. The retry + hdiutil fallback added earlier stays as belt-and-suspenders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 00d578e5..6d0ddd6b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2141,15 +2141,21 @@ jobs: run: | set -euo pipefail brew install create-dmg - DMG="QtMeshEditor-${{github.ref_name}}-MacOS.dmg" + # github.ref_name is "867/merge" on a PR — the slash makes + # create-dmg treat "QtMeshEditor-867/merge-MacOS.dmg" as a path, + # cd into the nonexistent "QtMeshEditor-867/" dir, and write the + # DMG under the wrong name (the real, deterministic cause of the + # macOS Pack failures on PRs, not a create-dmg flake). Sanitize + # any '/' out of the basename. Release tags (X.Y.Z) are unaffected. + REF="$(echo "${{github.ref_name}}" | tr '/' '-')" + DMG="QtMeshEditor-${REF}-MacOS.dmg" APP="${{github.workspace}}/bin/QtMeshEditor.app" - # create-dmg mounts a disk image and AppleScript-positions the - # icons; on CI that mount/detach races and fails intermittently - # (seen on PRs #866/#867). Retry a few times, and if it still + # create-dmg also mounts a disk image + AppleScript-positions the + # icons, which can race on CI. Retry a few times, and if it still # fails fall back to a plain hdiutil DMG so packaging never blocks # the build on a transient tooling flake. The pretty layout is - # only cosmetic — the fallback DMG installs identically. + # only cosmetic - the fallback DMG installs identically. make_pretty_dmg() { rm -f "$DMG" sudo create-dmg \