Skip to content

Commit 34b68f6

Browse files
Merge pull request #867 from fernandotonon/feat/arm-space-854
Mixamo-style arm-space post-process (#854)
2 parents e308a67 + c357807 commit 34b68f6

13 files changed

Lines changed: 884 additions & 20 deletions

.github/workflows/deploy.yml

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,19 +2138,55 @@ jobs:
21382138
file ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor
21392139
21402140
- name: Pack
2141-
run: |
2141+
run: |
2142+
set -euo pipefail
21422143
brew install create-dmg
2143-
sudo create-dmg \
2144-
--volname "QtMeshEditor Installer" \
2145-
--volicon "${{github.workspace}}/resources/icon.icns" \
2146-
--window-pos 200 120 \
2147-
--window-size 800 400 \
2148-
--icon-size 100 \
2149-
--icon "QtMeshEditor.app" 200 190 \
2150-
--app-drop-link 600 185 \
2151-
QtMeshEditor-${{github.ref_name}}-MacOS.dmg \
2152-
${{github.workspace}}/bin/QtMeshEditor.app
2153-
2144+
# github.ref_name is "867/merge" on a PR — the slash makes
2145+
# create-dmg treat "QtMeshEditor-867/merge-MacOS.dmg" as a path,
2146+
# cd into the nonexistent "QtMeshEditor-867/" dir, and write the
2147+
# DMG under the wrong name (the real, deterministic cause of the
2148+
# macOS Pack failures on PRs, not a create-dmg flake). Sanitize
2149+
# any '/' out of the basename. Release tags (X.Y.Z) are unaffected.
2150+
REF="$(echo "${{github.ref_name}}" | tr '/' '-')"
2151+
DMG="QtMeshEditor-${REF}-MacOS.dmg"
2152+
APP="${{github.workspace}}/bin/QtMeshEditor.app"
2153+
2154+
# create-dmg also mounts a disk image + AppleScript-positions the
2155+
# icons, which can race on CI. Retry a few times, and if it still
2156+
# fails fall back to a plain hdiutil DMG so packaging never blocks
2157+
# the build on a transient tooling flake. The pretty layout is
2158+
# only cosmetic - the fallback DMG installs identically.
2159+
make_pretty_dmg() {
2160+
rm -f "$DMG"
2161+
sudo create-dmg \
2162+
--volname "QtMeshEditor Installer" \
2163+
--volicon "${{github.workspace}}/resources/icon.icns" \
2164+
--window-pos 200 120 \
2165+
--window-size 800 400 \
2166+
--icon-size 100 \
2167+
--icon "QtMeshEditor.app" 200 190 \
2168+
--app-drop-link 600 185 \
2169+
"$DMG" "$APP"
2170+
}
2171+
ok=0
2172+
for attempt in 1 2 3; do
2173+
echo "create-dmg attempt ${attempt}..."
2174+
if make_pretty_dmg; then ok=1; break; fi
2175+
echo "create-dmg attempt ${attempt} failed; detaching stray mounts and retrying"
2176+
hdiutil detach "/Volumes/QtMeshEditor Installer" -force 2>/dev/null || true
2177+
sleep 5
2178+
done
2179+
if [ "$ok" -ne 1 ]; then
2180+
echo "create-dmg failed 3x - falling back to a plain hdiutil DMG"
2181+
rm -f "$DMG"
2182+
staging="$(mktemp -d)"
2183+
cp -R "$APP" "$staging/"
2184+
ln -s /Applications "$staging/Applications"
2185+
hdiutil create -volname "QtMeshEditor Installer" \
2186+
-srcfolder "$staging" -ov -format UDZO "$DMG"
2187+
fi
2188+
ls -la "$DMG"
2189+
21542190
- if: github.event_name == 'release' && github.event.action == 'published'
21552191
uses: actions/upload-artifact@v4
21562192
with:

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Large diffs are not rendered by default.

qml/PropertiesPanel.qml

Lines changed: 165 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8348,14 +8348,45 @@ Rectangle {
83488348
spacing: 4
83498349

83508350
property var entityGroups: PropertiesPanelController.animationData()
8351+
// #854: the clip the arm-space slider targets. Defaults to the last
8352+
// generated clip, but the per-row arm button can point it at ANY
8353+
// animation. Cleared on selection change.
8354+
property string lastGeneratedAnim: ""
8355+
property string armSpaceAnim: ""
8356+
property string armSpaceEntity: ""
83518357

83528358
function refreshAnimData() {
83538359
entityGroups = PropertiesPanelController.animationData()
83548360
}
83558361

8362+
// #854: point the arm-space slider at a clip. The adjustment
8363+
// STICKS to that clip (so it exports widened) and is independent
8364+
// per clip. Generation always produces a CLEAN clip (slider value
8365+
// never bakes into it), and the slider is seeded with the target
8366+
// clip's ACTUAL current angle — so switching clips shows the truth
8367+
// and a fresh generate shows 0. Closing the panel / reselecting
8368+
// just detaches the slider (no revert; the clip keeps its edit).
8369+
function setArmSpaceTarget(animName, entity) {
8370+
armSpaceAnim = animName
8371+
armSpaceEntity = entity
8372+
var cur = AnimationControlController.currentArmSpace(animName, entity)
8373+
armSpaceSlider.value = cur
8374+
armSpaceSlider.lastApplied = cur
8375+
}
8376+
function detachArmSpace() {
8377+
armSpaceAnim = ""
8378+
armSpaceEntity = ""
8379+
armSpaceSlider.value = 0
8380+
armSpaceSlider.lastApplied = 0
8381+
}
8382+
83568383
Connections {
83578384
target: PropertiesPanelController
8358-
function onSelectionChanged() { refreshAnimData() }
8385+
function onSelectionChanged() {
8386+
lastGeneratedAnim = ""
8387+
detachArmSpace()
8388+
refreshAnimData()
8389+
}
83598390
function onAnimationStateChanged() { refreshAnimData() }
83608391
}
83618392
// Morph clips (create/delete/rename) are mesh animations that show
@@ -8385,6 +8416,27 @@ Rectangle {
83858416
verticalAlignment: TextInput.AlignVCenter
83868417
color: PropertiesPanelController.textColor; font.pixelSize: 11
83878418
clip: true
8419+
// selectByMouse lets a click position the caret AND take
8420+
// keyboard focus — without it a bare TextInput embedded
8421+
// in a QQuickWidget stays unfocused on click (the other
8422+
// working fields in this panel all set it). activeFocus-
8423+
// OnPress is the default but stated for clarity.
8424+
selectByMouse: true
8425+
activeFocusOnPress: true
8426+
// Belt-and-suspenders focus grab: after using the
8427+
// arm-space slider / arm buttons (which take focus and
8428+
// can leave the QQuickWidget's focus on the viewport),
8429+
// a plain click here didn't always re-focus the field.
8430+
// Force it on press and let the event through (accepted
8431+
// = false) so selectByMouse still positions the caret.
8432+
MouseArea {
8433+
anchors.fill: parent
8434+
cursorShape: Qt.IBeamCursor
8435+
onPressed: function(mouse) {
8436+
genPromptIn.forceActiveFocus()
8437+
mouse.accepted = false
8438+
}
8439+
}
83888440
Text {
83898441
anchors.verticalCenter: parent.verticalCenter
83908442
visible: !genPromptIn.text && !genPromptIn.activeFocus
@@ -8409,8 +8461,19 @@ Rectangle {
84098461
? "Generating (experimental model)…"
84108462
: "Generating… (first use downloads the motion library)"
84118463
genStatus.isError = false
8412-
AnimationControlController.generateMotion(genPromptIn.text, 0.0,
8413-
useModelChk.checked)
8464+
// Generate a CLEAN clip — arm-space is always an
8465+
// explicit post-adjustment starting from 0, never baked
8466+
// into generation (a leftover slider value silently
8467+
// skewing every new clip is exactly the bug this fixes).
8468+
var gr = AnimationControlController.generateMotion(
8469+
genPromptIn.text, 0.0, useModelChk.checked,
8470+
0.0)
8471+
if (gr && gr.animation) {
8472+
lastGeneratedAnim = gr.animation
8473+
// Point the slider at the fresh clip at a neutral 0
8474+
// (reverts any prior target — see setArmSpaceTarget).
8475+
setArmSpaceTarget(gr.animation, gr.entity || "")
8476+
}
84148477
genBtnBusy = false
84158478
// generateMotion adds an AnimationState synchronously, but
84168479
// it lives on AnimationControlController — the Inspector
@@ -8447,6 +8510,62 @@ Rectangle {
84478510
anchors.verticalCenter: parent.verticalCenter
84488511
}
84498512
}
8513+
// ── Arm space (#854): Mixamo-style widen/tuck post-process ────────
8514+
// Targets `armSpaceAnim` — the last generated clip by default, or
8515+
// any animation the user picks via the per-row arm button below.
8516+
// The op is ABSOLUTE + idempotent, so dragging can re-apply live at
8517+
// each value (no accumulation) and the slider maps straight to the
8518+
// stored angle.
8519+
Row {
8520+
width: parent.width - 16; spacing: 6
8521+
visible: armSpaceAnim.length > 0
8522+
Text {
8523+
text: "Arm space"
8524+
color: PropertiesPanelController.textColor; font.pixelSize: 10
8525+
anchors.verticalCenter: parent.verticalCenter
8526+
width: 62
8527+
elide: Text.ElideRight
8528+
}
8529+
Slider {
8530+
id: armSpaceSlider
8531+
anchors.verticalCenter: parent.verticalCenter
8532+
width: parent.width - 62 - 42 - 12
8533+
from: -30; to: 45; value: 0; stepSize: 1
8534+
// Live update: adjustArmSpace is absolute+idempotent, so
8535+
// firing it per value while dragging just re-applies (never
8536+
// accumulates). Throttle to whole degrees via stepSize so we
8537+
// rewrite keyframes at most ~75 times across the range.
8538+
property real lastApplied: 0
8539+
onValueChanged: {
8540+
if (armSpaceAnim.length > 0
8541+
&& Math.round(value) !== Math.round(lastApplied)) {
8542+
lastApplied = value
8543+
AnimationControlController.adjustArmSpace(
8544+
armSpaceAnim, value, armSpaceEntity)
8545+
}
8546+
}
8547+
// Refresh the Inspector list only on release (the viewport
8548+
// itself updates live inside adjustArmSpace).
8549+
onPressedChanged: { if (!pressed) refreshAnimData() }
8550+
}
8551+
Text {
8552+
text: (armSpaceSlider.value > 0 ? "+" : "")
8553+
+ Math.round(armSpaceSlider.value) + "°"
8554+
color: PropertiesPanelController.textColor; font.pixelSize: 10
8555+
anchors.verticalCenter: parent.verticalCenter
8556+
width: 42; horizontalAlignment: Text.AlignRight
8557+
}
8558+
}
8559+
// Label showing which clip the slider affects (only when it's not
8560+
// the obvious just-generated one).
8561+
Text {
8562+
visible: armSpaceAnim.length > 0
8563+
width: parent.width - 16; wrapMode: Text.Wrap
8564+
text: "↑ adjusting \"" + armSpaceAnim + "\" (0 = no change). The "
8565+
+ "edit stays on this clip; click ↔ again to detach."
8566+
color: PropertiesPanelController.textColor; opacity: 0.5
8567+
font.pixelSize: 9
8568+
}
84508569
Text {
84518570
id: genStatus
84528571
property bool isError: false
@@ -8955,6 +9074,49 @@ Rectangle {
89559074
}
89569075
}
89579076

9077+
// Arm space (#854) — point the arm-space
9078+
// slider (above) at THIS clip. Works on any
9079+
// skeletal animation, not just generated
9080+
// ones. Highlights when it's the active target.
9081+
Rectangle {
9082+
id: armBtn
9083+
visible: grp.hasSkeleton
9084+
width: 22; height: 18; radius: 3
9085+
anchors.verticalCenter: parent.verticalCenter
9086+
property bool active: armSpaceAnim === modelData.name
9087+
&& armSpaceEntity === grp.entity
9088+
color: active ? PropertiesPanelController.highlightColor
9089+
: armMouse.pressed ? Qt.darker(PropertiesPanelController.headerColor, 1.2)
9090+
: armMouse.containsMouse ? Qt.lighter(PropertiesPanelController.headerColor, 1.2)
9091+
: PropertiesPanelController.headerColor
9092+
border.color: PropertiesPanelController.borderColor; border.width: 1
9093+
Text {
9094+
anchors.centerIn: parent
9095+
text: "" // ↔ widen/tuck
9096+
color: armBtn.active ? "white"
9097+
: PropertiesPanelController.textColor
9098+
font.pixelSize: 12
9099+
}
9100+
ToolTip.visible: armMouse.containsMouse
9101+
ToolTip.delay: 600
9102+
ToolTip.text: "Arm space — retarget the widen/tuck slider to this animation"
9103+
MouseArea {
9104+
id: armMouse; anchors.fill: parent; hoverEnabled: true
9105+
cursorShape: Qt.PointingHandCursor
9106+
onClicked: {
9107+
// Toggle: detach if it's already
9108+
// the target (the clip keeps its
9109+
// edit), else target this clip
9110+
// (seeded with its real angle).
9111+
if (armBtn.active)
9112+
detachArmSpace()
9113+
else
9114+
setArmSpaceTarget(
9115+
modelData.name, grp.entity)
9116+
}
9117+
}
9118+
}
9119+
89589120
// Bake — resample / reduce every bone track in this
89599121
// animation. Mirrors the curve editor's per-bone Bake
89609122
// dropdown but applies to the whole animation in one

src/AnimationControlController.cpp

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1769,8 +1769,71 @@ QVariantMap AnimationControlController::inbetweenWindow(double t0, double t1,
17691769
return out;
17701770
}
17711771

1772+
bool AnimationControlController::adjustArmSpace(const QString& animName,
1773+
double degrees,
1774+
const QString& entityName)
1775+
{
1776+
Manager* mgr = Manager::getSingletonPtr();
1777+
if (!mgr) return false;
1778+
const std::string want = entityName.isEmpty()
1779+
? m_selectedEntityName : entityName.toStdString();
1780+
Ogre::Entity* entity = nullptr;
1781+
for (auto* e : mgr->getEntities()) {
1782+
if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue;
1783+
if (want.empty() || e->getName() == want) { entity = e; break; }
1784+
}
1785+
if (!entity) return false;
1786+
Ogre::SkeletonPtr skel = entity->getMesh()->getSkeleton();
1787+
const std::string an = animName.toStdString();
1788+
if (!skel || !skel->hasAnimation(an)) return false;
1789+
1790+
SentryReporter::addBreadcrumb(QStringLiteral("ui.action"),
1791+
QStringLiteral("GUI arm_space %1 deg").arg(degrees));
1792+
if (!AnimationMerger::adjustArmSpace(skel.get(), an,
1793+
static_cast<float>(degrees)))
1794+
return false;
1795+
1796+
// Re-pose the mesh NOW, even when the clip is paused. Rewriting keyframes
1797+
// doesn't move the bones until the animation state re-applies; when
1798+
// playback is stopped nothing ticks it, so the viewport would stay frozen
1799+
// on the pre-edit pose until the user hits play. Mark the state dirty and
1800+
// re-stamp its current time to force an immediate re-evaluation (the same
1801+
// idiom as notifyOgreUpdate, but targeting THIS entity + clip so it works
1802+
// for any animation, not just the selected one).
1803+
entity->refreshAvailableAnimationState();
1804+
if (auto* states = entity->getAllAnimationStates()) {
1805+
states->_notifyDirty();
1806+
if (states->hasAnimationState(an)) {
1807+
auto* st = states->getAnimationState(an);
1808+
if (st->getEnabled())
1809+
st->setTimePosition(st->getTimePosition());
1810+
}
1811+
}
1812+
notifyExternalAnimationEdit();
1813+
return true;
1814+
}
1815+
1816+
double AnimationControlController::currentArmSpace(const QString& animName,
1817+
const QString& entityName)
1818+
{
1819+
Manager* mgr = Manager::getSingletonPtr();
1820+
if (!mgr) return 0.0;
1821+
const std::string want = entityName.isEmpty()
1822+
? m_selectedEntityName : entityName.toStdString();
1823+
for (auto* e : mgr->getEntities()) {
1824+
if (!e || e->getMovableType() != "Entity" || !e->hasSkeleton()) continue;
1825+
if (want.empty() || e->getName() == want) {
1826+
Ogre::SkeletonPtr skel = e->getMesh()->getSkeleton();
1827+
return skel ? AnimationMerger::currentArmSpace(
1828+
skel.get(), animName.toStdString()) : 0.0;
1829+
}
1830+
}
1831+
return 0.0;
1832+
}
1833+
17721834
QVariantMap AnimationControlController::generateMotion(const QString& prompt,
1773-
double duration, bool useModel)
1835+
double duration, bool useModel,
1836+
double armSpaceDeg)
17741837
{
17751838
QVariantMap out;
17761839
out["ok"] = false;
@@ -1869,6 +1932,11 @@ QVariantMap AnimationControlController::generateMotion(const QString& prompt,
18691932
if (!res.ok) return fail(res.error);
18701933
out["source"] = clipSource;
18711934

1935+
// #854: optional Mixamo-style arm-space post-process.
1936+
if (std::abs(armSpaceDeg) > 1e-4)
1937+
AnimationMerger::adjustArmSpace(skel.get(), animName,
1938+
static_cast<float>(armSpaceDeg));
1939+
18721940
entity->refreshAvailableAnimationState();
18731941
// Make the generated clip the ONLY enabled animation. Ogre AVERAGES all
18741942
// enabled animation states, so leaving the import's auto-enabled clip (or
@@ -1904,6 +1972,7 @@ QVariantMap AnimationControlController::generateMotion(const QString& prompt,
19041972
out["action"] = action;
19051973
out["source"] = clipSource;
19061974
out["animation"] = QString::fromStdString(animName);
1975+
out["entity"] = QString::fromStdString(entity->getName());
19071976
out["frames"] = res.frames;
19081977
out["length"] = res.length;
19091978
out["tracksWritten"] = res.tracksWritten;

0 commit comments

Comments
 (0)