From 4d7146ec550784891369d5f16eb51cff3833ba63 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 06:11:37 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(morph):=20sub-slice=20A2=20=E2=80=94?= =?UTF-8?q?=20Inspector=20Morph=20Targets=20subgroup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sub-slice of #518. A1 shipped the data path (importer + MorphAnimationManager + CLI list); A2 wires the QML Inspector surface that authors will actually use. ### What ships New "Morph Targets (N)" subgroup at the bottom of the Animations section in `qml/PropertiesPanel.qml`: - Bound to `MorphAnimationManager.morphTargetsForSelection()` — refreshes via the `morphTargetsChanged` signal when selection moves. - One row per target: name (elided), 0..1 slider (step 0.01), numeric readout. Slider `onMoved` writes back through `setWeightForSelection`, which drives the underlying Ogre `AnimationState` weight (the live preview path). - "Reset all" button — walks every target and sets weight to 0. - Filter / search text field, shown when target count > 6 (characters routinely ship 50+ shapes; flat list is hopeless). - Whole subgroup is `visible: targetCount > 0` so meshes without blend shapes don't show an empty header. Authoring (add new target from edit-mode delta, rename, delete) lands in A3. Export round-trip in A4. Dope-sheet integration in A5. MCP tools in A6. ### Manual smoke - Select an entity with blend shapes (any FBX from a DCC with facial rigs). The "Morph Targets (N)" subgroup appears at the bottom of the Animations section with one slider per shape. - Drag a slider — the mesh deforms in real time (the AnimationState weight drives the VAT_POSE track A1 created). - Click "Reset all" — every slider snaps back to 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 120 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index e5031b3a..f263e9f0 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4306,6 +4306,126 @@ Rectangle { } } } + + // ---- Morph Targets / Blend Shapes (slice A2) ---- + // Per-pose weight sliders, sourced from MorphAnimationManager. + // Lives at the bottom of the Animations section, outside the + // per-entity repeater above — morph data is read from the + // SelectionSet's first entity to keep the surface focused. + // Authoring (add/rename/delete) lands in A3. + Rectangle { + width: parent.width - 16 + visible: morphCol.targetCount > 0 + height: morphCol.implicitHeight + 12 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + + Column { + id: morphCol + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + + property var targets: MorphAnimationManager.morphTargetsForSelection() + property int targetCount: targets.length + property string filter: "" + + Connections { + target: MorphAnimationManager + function onMorphTargetsChanged() { + morphCol.targets = MorphAnimationManager.morphTargetsForSelection() + } + } + + Row { + spacing: 4 + width: parent.width + Text { + text: "Morph Targets (" + morphCol.targetCount + ")" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + Item { width: parent.width - 260; height: 1 } + // Reset all: walks every target and sets weight to 0. + Rectangle { + width: 60; height: 20; radius: 3 + color: resetMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + anchors.verticalCenter: parent.verticalCenter + Text { + anchors.centerIn: parent + text: "Reset all" + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: resetMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + for (var i = 0; i < morphCol.targets.length; ++i) + MorphAnimationManager.setWeightForSelection(morphCol.targets[i], 0) + } + } + } + } + + // Filter / search — characters often have 50+ blend + // shapes, scanning a flat list is hopeless without + // a typeahead box. + TextField { + id: filterField + width: parent.width + placeholderText: "Filter targets…" + font.pixelSize: 10 + onTextChanged: morphCol.filter = text + visible: morphCol.targetCount > 6 + } + + // One row per target. Hidden when filter doesn't match. + Repeater { + model: morphCol.targets + Row { + width: morphCol.width + spacing: 4 + visible: morphCol.filter === "" + || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 + height: visible ? 22 : 0 + + Text { + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 10 + width: 120 + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + } + Slider { + id: weightSlider + from: 0; to: 1; stepSize: 0.01 + width: parent.width - 200 + value: MorphAnimationManager.weightForSelection(modelData) + anchors.verticalCenter: parent.verticalCenter + onMoved: MorphAnimationManager.setWeightForSelection(modelData, value) + } + Text { + text: weightSlider.value.toFixed(2) + color: PropertiesPanelController.textColor + font.pixelSize: 10 + width: 36 + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } } } From bcfa6ef9abf57ea60ec667c19d32ff2bb6cea942 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 06:30:06 -0400 Subject: [PATCH 2/3] =?UTF-8?q?review(morph):=20address=20Codex=20finding?= =?UTF-8?q?=20on=20A2=20=E2=80=94=20slider=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1 on PR #570: The morph slider's `value` was bound to a one-shot function call (`MorphAnimationManager.weightForSelection(modelData)`), evaluated once at delegate creation. Any weight write that doesn't move the slider directly — Reset all, MCP, future dope-sheet scrubs — would update the backend AnimationState but the visible slider + numeric readout stayed stale until the delegate was recreated. From the user's perspective, "Reset all" appeared to do nothing. Fix: track a `weightTick` counter that bumps on `morphTargetsChanged` and `morphWeightChanged`, then bind the slider's `value` to a comma-expression that reads the tick before calling the weight getter — Qt's binding machinery picks up the tick dependency and re-evaluates the function when it changes. Same pattern QML typically uses to force re-evaluation of function-bound bindings. Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index f263e9f0..ce7d00b6 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4331,11 +4331,22 @@ Rectangle { property var targets: MorphAnimationManager.morphTargetsForSelection() property int targetCount: targets.length property string filter: "" + // Bumped on `morphWeightChanged`; sliders bind their + // `value` to a function call gated on this counter so + // weight changes from any code path (Reset all, + // dope-sheet scrubs in later slices, MCP, etc.) flow + // back into the UI rather than going stale until the + // delegate is recreated. + property int weightTick: 0 Connections { target: MorphAnimationManager function onMorphTargetsChanged() { morphCol.targets = MorphAnimationManager.morphTargetsForSelection() + morphCol.weightTick = morphCol.weightTick + 1 + } + function onMorphWeightChanged(entity, name, weight) { + morphCol.weightTick = morphCol.weightTick + 1 } } @@ -4411,7 +4422,11 @@ Rectangle { id: weightSlider from: 0; to: 1; stepSize: 0.01 width: parent.width - 200 - value: MorphAnimationManager.weightForSelection(modelData) + // Bind to `weightTick` so changes that + // bypass user drag (Reset all, MCP, future + // dope-sheet scrubs) refresh the readout. + value: (morphCol.weightTick, + MorphAnimationManager.weightForSelection(modelData)) anchors.verticalCenter: parent.verticalCenter onMoved: MorphAnimationManager.setWeightForSelection(modelData, value) } From 8f9f86cdd02e3948a18968ea20eebee96acafd15 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 17 May 2026 06:47:28 -0400 Subject: [PATCH 3/3] =?UTF-8?q?review(morph):=20A2=20=E2=80=94=20defensive?= =?UTF-8?q?=20`||=20[]`=20on=20morph=20targets=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit nit on PR #570. The manager currently always returns a QStringList, but contracts drift; the `|| []` fallback keeps the delegate Repeater binding safe if the getter ever starts returning null/undefined. Applied at both call sites (property initialiser + onMorphTargetsChanged handler). Co-Authored-By: Claude Opus 4.7 (1M context) --- qml/PropertiesPanel.qml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index ce7d00b6..9a8c594c 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4328,7 +4328,10 @@ Rectangle { anchors.margins: 6 spacing: 4 - property var targets: MorphAnimationManager.morphTargetsForSelection() + // Defensive `|| []` so an unexpected null return + // doesn't crash the binding — the manager currently + // always returns a QStringList, but contracts drift. + property var targets: MorphAnimationManager.morphTargetsForSelection() || [] property int targetCount: targets.length property string filter: "" // Bumped on `morphWeightChanged`; sliders bind their @@ -4342,7 +4345,7 @@ Rectangle { Connections { target: MorphAnimationManager function onMorphTargetsChanged() { - morphCol.targets = MorphAnimationManager.morphTargetsForSelection() + morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] morphCol.weightTick = morphCol.weightTick + 1 } function onMorphWeightChanged(entity, name, weight) {