diff --git a/CLAUDE.md b/CLAUDE.md index e826b4bbf..be9ae1d64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,8 @@ qtmesh info model.fbx --json # show mesh info (JSON) qtmesh convert model.fbx -o model.gltf2 # convert between formats qtmesh fix model.fbx -o fixed.fbx # re-import/export with standard optimizations qtmesh fix model.fbx --all # apply all extra fixes (remove degenerates, merge materials) +qtmesh anim cache.abc --info # Alembic vertex-cache metadata (frames/verts/fps/duration; needs -DENABLE_ALEMBIC) +qtmesh anim cache.abc --info --json # same, as JSON qtmesh anim model.fbx --list # list animations qtmesh anim model.fbx --list --json # list animations (JSON) qtmesh anim model.fbx --rename "Take 001" "Idle" -o out.fbx # rename an animation @@ -231,6 +233,15 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Subdivide**: 1-to-4 triangle split. Adjacent non-selected faces are retriangulated against the new midpoints to avoid T-junctions (1/2/3 split-edge cases). Wired to a toolbar button (⊞) — face mode subdivides selected tris, edge mode subdivides every triangle incident to a selected edge. - **Fill**: vertex mode fan-triangulates the selected verts (3 → triangle, 4 → quad, N → N-2 tris); edge mode detects a closed boundary loop via degree-2 walk and caps it. Toolbar button (◆) and `F` shortcut. Cross-submesh inputs and duplicates of existing triangles are rejected. +### Animation systems beyond skeletal (epic #517) + +The animation pipeline started skeleton-only; the #517 epic broadens it. Slices A/C/D shipped: **MorphAnimationManager** (`src/MorphAnimationManager.{h,cpp}`, morph targets / blend shapes — Ogre `Pose` + `VAT_POSE` tracks, `qtmesh morph` CLI, MCP, undo via `commands/MorphCommands`), **NodeAnimationManager** (non-skinned node TRS), **PoseLibrary** (named poses). All are `QML_SINGLETON`s registered in `mainwindow.cpp`. + +- **Morph authoring UX (Blender-parity, #519):** morph targets are authored/edited/reordered in the Edit-Mode **"Vertex Morph Animation"** Inspector group (NOT Animation Mode — Animation Mode's list shows only real clips, morph animations are filtered out of `PropertiesPanelController::animationData()` by name). Authoring is a **non-destructive sculpt session**: `EditModeController::beginMorphSculpt()` snapshots base positions; `+ Add` captures the current edit as a target (delta vs base); `endMorphSculpt()` / exiting Edit Mode **restores the base** (the target lives on as a `Pose` + weight). `EditableMesh::commitToEntity` refreshes the pose buffer (`AnimationStateSet::_notifyDirty()` + `entity->_updateAnimation()`) for vertex-animated entities so edits render live while the frame loop is paused. Reorder via `MorphAnimationManager::moveMorphTarget`/`moveMorphTargetToIndex` (undoable `ReorderMorphTargetsCommand` — VAT_POSE keyframes reference poses by index, so it rebuilds all targets in the new order). **Weight keyframing over time** (Slice 2): `setMorphWeightKeyframe(name, time, weight)` writes to one shared clip `MorphAnimationManager::kWeightClipName` ("MorphAnim") — a VAT_POSE track per target's pose, each keyframe referencing the pose at influence == weight; the per-target "◈ Key" button records the weight at the timeline playhead (`AnimationControlController.sliderValue`), diamonds show in the dope sheet (`allMorphRows()` prefers the MorphAnim track's per-pose keyframe times). **glTF export:** `buildAiScene` emits blend-shape targets (`aiMesh::mAnimMeshes` via `attachMorphTargetsToAiMesh`) AND a morph-weights animation (`aiMeshMorphAnim` from the MorphAnim clip). FBX blend-shape weight export is a follow-up. + +- **VertexAnimationManager** (`src/VertexAnimationManager.{h,cpp}`, Slice B #519): full-mesh per-vertex animation (cloth / sims / fluid bakes / Alembic caches — every vertex moves, no skeleton). Reuses Ogre's `VAT_POSE` path so the existing timeline/dope-sheet/loop play it with no new playback code. `FrameSet`/`FrameData` are the source-agnostic decoded-cache types; `buildClipFromFrames(mesh, name, frames)` reads submesh-0 bind positions and builds one `Ogre::Pose` per frame (delta vs bind) + one `VAT_POSE` track keyed per frame time. `sampleHeuristic(frameCount)` (< 32 → poses, else stream — the issue's rule) is static + unit-tested. Sentry `scene.anim.vertex_anim`. +- **AlembicImporter** (`src/AlembicImporter.{h,cpp}`, Slice B2): Alembic (.abc) reader behind `-DENABLE_ALEMBIC` (default OFF; `cmake/Alembic.cmake` FetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditional `install(EXPORT)` finds it in an export set). `readFrameSet` decodes the first `IPolyMesh` into a `FrameSet` (pure data — rejects variable-topology caches, fan-triangulates n-gon faces); `importToScene` builds the base mesh + `VAT_POSE` clip + entity. `.abc` routes through `MeshImporterExporter::importer` (guarded — a non-Alembic build logs a clear "rebuild with -DENABLE_ALEMBIC" and skips). The reader is `#ifdef ENABLE_ALEMBIC`-guarded so the default build is unaffected; a round-trip test writes+reads a synthetic `.abc` (only compiled/run in the Alembic-on coverage CI lane). **B3 (shipped):** `readInfo(path)` reads cache metadata (frames/verts/tris/fps/duration/storage) from the schema header + first sample without decoding all frames → **`qtmesh anim .abc --info [--json]`** (`CLIPipeline::cmdAnim`); MCP **`import_alembic`** (heavy — decodes into the live scene, reports node/entities/vertexClips) + **`play_vertex_animation`** (delegates to `toolPlayAnimation` since a vertex clip is an ordinary `AnimationState`). Frame cap: `readFrameSet(maxFrames)` and `importToScene` cap the decode at 512 frames and set `ReadResult::truncated` / log a warning when it bites (no silent cap) — VAT_POSE holds every frame resident, so true per-frame vertex-buffer streaming remains future work. + ### Undo/Redo System - **UndoManager** (`src/UndoManager.h/cpp`): Singleton wrapping `QUndoStack`. Push commands, undo/redo via `Ctrl+Z`/`Ctrl+Shift+Z`. diff --git a/CMakeLists.txt b/CMakeLists.txt index e26a6d6cb..724433f4f 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -306,6 +306,24 @@ if(ENABLE_ONNX) message(STATUS "AI PBR map synthesis enabled with ONNX Runtime") endif() ############################################################## +# Alembic — mesh / vertex animation import (#519, Anim Slice B) +############################################################## +# Vendored (BSD) reader for baked per-vertex caches (cloth / sims / fluids / +# Houdini + Blender exports). Default OFF: the Alembic + Imath build is +# non-trivial across the three target platforms. VertexAnimationManager + Ogre +# VAT_POSE playback work WITHOUT Alembic; this flag adds the .abc reader +# (cmake/Alembic.cmake, FetchContent). The C++ #ifdef ENABLE_ALEMBIC guards the +# reader so a build without it still compiles and skips .abc with a clear message. +option(ENABLE_ALEMBIC "Enable Alembic (.abc) vertex-animation import" OFF) + +if(ENABLE_ALEMBIC) + include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Alembic.cmake) + add_definitions(-DENABLE_ALEMBIC) + message(STATUS "Alembic vertex-animation import enabled") +else() + message(STATUS "Alembic vertex-animation import disabled (VAT_POSE playback still available)") +endif() +############################################################## # PS1 runtime geometry extraction (experimental) ############################################################## option(ENABLE_PS1_RIP "Enable experimental PS1 runtime geometry extraction" OFF) diff --git a/cmake/Alembic.cmake b/cmake/Alembic.cmake new file mode 100644 index 000000000..d0382833a --- /dev/null +++ b/cmake/Alembic.cmake @@ -0,0 +1,92 @@ +# Alembic (.abc) vertex-animation import — Anim epic Slice B (#519), sub-slice B2. +# +# Vendors Imath 3 + Alembic (both BSD-3-Clause) via FetchContent, statically, +# with every optional component OFF (no HDF5, Python, tests, binaries, install). +# Exposes an imported target `qtmesh_alembic` that the app links; the C++ side +# is #ifdef ENABLE_ALEMBIC-guarded so a build without this still compiles. +# +# Why build from source rather than find_package: Alembic + Imath system +# packages are absent or version-skewed across our three targets (macOS via +# brew, Ubuntu CI, Windows MinGW). FetchContent gives one reproducible version +# everywhere, matching how the project already vendors ONNX Runtime / libsodium +# / tinyexr. +# +# Dependency chain: Alembic FIND_PACKAGE(Imath) → we build Imath as a +# subproject first, point Imath_DIR at its generated build-tree config so +# Alembic's find_package(Imath CONFIG) resolves to the target we just built. + +if(TARGET qtmesh_alembic) + return() +endif() + +include(FetchContent) + +set(QTMESH_IMATH_TAG "v3.1.12" CACHE STRING "Imath git tag") +set(QTMESH_ALEMBIC_TAG "1.8.8" CACHE STRING "Alembic git tag") + +# ---- Imath --------------------------------------------------------------- +# Static, no tests/python. IMATH_INSTALL stays ON: Alembic's own +# INSTALL(EXPORT AlembicTargets) is unconditional and references Imath, so +# Imath MUST be in an export set too or CMake's generate step fails +# ("target Alembic requires target Imath that is not in any export set"). +# We never actually run `make install` from the app build, so an ON install +# rule is harmless — it just keeps the export sets consistent. +set(IMATH_INSTALL ON CACHE BOOL "" FORCE) +set(IMATH_INSTALL_PKG_CONFIG OFF CACHE BOOL "" FORCE) +set(PYTHON OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +FetchContent_Declare( + qtmesh_imath + GIT_REPOSITORY https://github.com/AcademySoftwareFoundation/Imath.git + GIT_TAG ${QTMESH_IMATH_TAG} + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(qtmesh_imath) + +# Alembic's find_package(Imath) resolves against a CONFIG. Imath-as-subproject +# writes its package config under the build dir; point find_package there. +if(NOT DEFINED Imath_DIR) + set(Imath_DIR "${qtmesh_imath_BINARY_DIR}/config" CACHE PATH "" FORCE) +endif() + +# ---- Alembic ------------------------------------------------------------- +# Everything optional OFF: no HDF5 backend (we only read the modern Ogawa +# backend), no tests/binaries/python/prman/maya/arnold, static lib. +set(USE_HDF5 OFF CACHE BOOL "" FORCE) +set(USE_TESTS OFF CACHE BOOL "" FORCE) +set(USE_BINARIES OFF CACHE BOOL "" FORCE) +set(USE_EXAMPLES OFF CACHE BOOL "" FORCE) +set(USE_PYALEMBIC OFF CACHE BOOL "" FORCE) +set(USE_ARNOLD OFF CACHE BOOL "" FORCE) +set(USE_PRMAN OFF CACHE BOOL "" FORCE) +set(USE_MAYA OFF CACHE BOOL "" FORCE) +set(ALEMBIC_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(ALEMBIC_ILMBASE_LINK_STATIC ON CACHE BOOL "" FORCE) +set(ALEMBIC_LIB_INSTALL_DIR "lib" CACHE STRING "" FORCE) + +FetchContent_Declare( + qtmesh_alembic + GIT_REPOSITORY https://github.com/alembic/alembic.git + GIT_TAG ${QTMESH_ALEMBIC_TAG} + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(qtmesh_alembic) + +# Alembic's core library target is `Alembic` (with an `Alembic::Alembic` alias +# in recent versions). Wrap whichever exists behind our stable name so the app +# links `qtmesh_alembic` regardless. +if(TARGET Alembic::Alembic) + add_library(qtmesh_alembic INTERFACE) + target_link_libraries(qtmesh_alembic INTERFACE Alembic::Alembic Imath::Imath) +elseif(TARGET Alembic) + add_library(qtmesh_alembic INTERFACE) + target_link_libraries(qtmesh_alembic INTERFACE Alembic Imath::Imath) + # The plain `Alembic` target's public include dirs cover its own headers; + # Imath::Imath carries the Imath headers Alembic's public API exposes. +else() + message(FATAL_ERROR "Alembic.cmake: neither Alembic nor Alembic::Alembic target was created") +endif() + +message(STATUS "Alembic ${QTMESH_ALEMBIC_TAG} + Imath ${QTMESH_IMATH_TAG} vendored (static)") diff --git a/qml/AnimationDopeSheet.qml b/qml/AnimationDopeSheet.qml index e91cf014e..e85c58bb4 100644 --- a/qml/AnimationDopeSheet.qml +++ b/qml/AnimationDopeSheet.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import AnimationControl 1.0 +import PropertiesPanel 1.0 // Multi-bone dope sheet with multi-select, bulk move, and copy/paste. // One row per animated bone with diamond markers at each keyframe time. @@ -164,6 +165,15 @@ Rectangle { function onKeyframeTicksChanged() { root.rows = AnimationControlController.allBoneRows() } } + // Morph weight keyframes changed (add/move/delete/key-at-playhead) → rebuild + // the morph band so its diamonds reflect the new times immediately. + Connections { + target: MorphAnimationManager + function onMorphTargetsChanged() { + root.morphRows = AnimationControlController.allMorphRows() + } + } + // Cross-platform "primary" modifier — Ctrl on Win/Linux, Cmd (Meta) on macOS. function isPrimaryModifier(modifiers) { return (modifiers & Qt.ControlModifier) || (modifiers & Qt.MetaModifier) @@ -205,12 +215,15 @@ Rectangle { } // ── Empty-state placeholder ────────────────────────────────────────────── + // Only "empty" when there are neither bone rows NOR morph rows — a mesh with + // morph targets (and no skeleton) still has a populated dope sheet, so the + // "select a rigged mesh" message must not show for it. Text { anchors.centerIn: parent - visible: root.rows.length === 0 + visible: root.rows.length === 0 && root.morphRows.length === 0 text: AnimationControlController.hasAnimation ? "No animated bones in this clip." - : "Select a rigged mesh and an animation to view its keyframes." + : "Select a rigged mesh, or add morph targets, to view keyframes." color: AnimationControlController.disabledTextColor font.pixelSize: 12 } @@ -891,9 +904,18 @@ Rectangle { id: morphBand anchors.left: parent.left anchors.right: parent.right - anchors.bottom: parent.bottom visible: morphRowsRep.count > 0 + // When there ARE bone tracks, the band docks to the BOTTOM under them + // (capped at 40% so it can't shove the bone rows off-screen). When there + // are NO bone tracks (morph-only mesh), it fills the whole sheet from the + // header down — otherwise it left an empty gap where the old skeleton + // message used to sit. + readonly property bool boneRowsPresent: root.rows.length > 0 + anchors.top: boneRowsPresent ? undefined : (header.visible ? header.bottom : parent.top) + anchors.bottom: parent.bottom + anchors.topMargin: boneRowsPresent ? 0 : 2 + // Cap the band at ~40% of the dope-sheet height so high-count // blendshape rigs (Mixamo characters routinely ship 50+) can't // push the bone tracks off-screen. When the content needs more @@ -903,9 +925,10 @@ Rectangle { readonly property int maxBandHeight: Math.max(morphHeader.height + root.rowHeight + 6, Math.floor(root.height * 0.4)) - height: visible - ? Math.min(naturalContentHeight, maxBandHeight) - : 0 + // Morph-only: fill (top+bottom anchors set height). With bones: capped. + height: !visible ? 0 + : boneRowsPresent ? Math.min(naturalContentHeight, maxBandHeight) + : undefined color: AnimationControlController.panelColor border.color: AnimationControlController.borderColor border.width: 1 @@ -977,27 +1000,102 @@ Rectangle { } } - // Right side: diamond per keyframe time. Sharing - // the bone-row timeline math so they line up - // vertically. `clip: true` so off-screen diamonds - // (from pan/zoom) don't bleed into the name strip - // or neighboring rows, matching the bone tracks. + // Right side: interactive diamonds. Drag to move the + // keyframe time, right-click to delete, double-click an + // empty spot to add a key at that time (weight = the + // target's current weight). Shares the bone-row timeline + // math so morph + bone diamonds line up vertically. Item { + id: morphTrackArea + property string morphName: modelData.name anchors.left: parent.left; anchors.leftMargin: root.leftStripWidth anchors.right: parent.right height: root.rowHeight clip: true + + Rectangle { + anchors.left: parent.left; anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: AnimationControlController.borderColor + opacity: 0.4 + } + + // Empty-area handler: double-click adds a key at the + // clicked time. Sits UNDER the diamonds so their own + // MouseAreas win for drag/delete. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onDoubleClicked: function(mouse) { + var t = mouse.x / root.pxPerSec + root.viewStart + if (t < 0) t = 0 + var w = MorphAnimationManager.weightForSelection(morphTrackArea.morphName) + MorphAnimationManager.setMorphWeightKeyframe( + morphTrackArea.morphName, t, w) + MorphAnimationManager.activateWeightClip() + AnimationControlController.sliderValue = Math.round(t * 1000) + } + } + Repeater { model: modelData.keyTimes Rectangle { + id: morphDiamond property real keyTime: modelData - x: (keyTime - root.viewStart) * root.pxPerSec - width / 2 + property real dragPreviewTime: keyTime + property real displayTime: dragMorph.dragging ? dragPreviewTime : keyTime + x: (displayTime - root.viewStart) * root.pxPerSec - width / 2 anchors.verticalCenter: parent.verticalCenter - width: 10; height: 10 + width: dragMorph.dragging ? 14 : 10 + height: width rotation: 45 - color: "#88ccff" // visually distinct from bone keys (yellow) + color: dragMorph.dragging ? "#ffaa55" : "#88ccff" border.color: AnimationControlController.borderColor border.width: 1 + + MouseArea { + id: dragMorph + anchors.fill: parent + anchors.margins: -3 + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.SizeHorCursor + preventStealing: true + property bool dragging: false + property real pressX: 0 + property real originalTime: 0 + onPressed: function(mouse) { + root.forceActiveFocus() + if (mouse.button === Qt.RightButton) { + MorphAnimationManager.clearMorphWeightKeyframe( + morphTrackArea.morphName, morphDiamond.keyTime) + mouse.accepted = true + return + } + originalTime = morphDiamond.keyTime + pressX = mouse.x + dragging = true + AnimationControlController.sliderValue = + Math.round(morphDiamond.keyTime * 1000) + mouse.accepted = true + } + onPositionChanged: function(mouse) { + if (!dragging) return + var dt = (mouse.x - pressX) / root.pxPerSec + var target = originalTime + dt + if (target < 0) target = 0 + morphDiamond.dragPreviewTime = target + } + onReleased: function(mouse) { + if (!dragging) return + dragging = false + var target = morphDiamond.dragPreviewTime + if (Math.abs(target - originalTime) > 0.001) { + MorphAnimationManager.moveMorphWeightKeyframe( + morphTrackArea.morphName, originalTime, target) + } + } + } } } } diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index eb2832f45..232ddfd4d 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -534,6 +534,21 @@ Rectangle { Component.onCompleted: content = editModeToolsComponent } + // ---- Vertex Morph Animation (Edit Mode) ---- + // Morph-target authoring belongs in Edit Mode: you sculpt vertices, + // then capture them as a target. (Previously the only morph UI lived + // in the Animation-Mode "Animations" section, whose "+ Add…" needed + // Edit Mode — an unreachable chicken-and-egg.) + CollapsibleSection { + title: "Vertex Morph Animation" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.EditMode, + EditModeController.editModeActive) + expanded: true + + Component.onCompleted: content = vertexMorphComponent + } + // ---- AI: Image → 3D (epic #764, Object mode) ---- CollapsibleSection { title: "AI: Image → 3D" @@ -721,22 +736,33 @@ Rectangle { // ---- Animations ---- // Shown for any skeleton-bearing selection (not just clips) so the // "Generate from text" control is available on a freshly-rigged mesh - // (e.g. a UniRig auto-rig with no animations yet). + // (e.g. a UniRig auto-rig with no animations yet). ALSO shown when + // the selection carries mesh/vertex animations (morph or Alembic + // vertex caches, #518/#519) even with no skeleton — otherwise those + // clips have no play/enable/loop controls (the "no play button" bug). CollapsibleSection { title: "Animations" sectionVisible: root.modeToolSectionVisible( EditorModeController.AnimationMode, - PropertiesPanelController.hasSkeletonSelection) + PropertiesPanelController.hasSkeletonSelection + || PropertiesPanelController.hasAnimations) Component.onCompleted: content = animationComponent } // ---- Animation Control (keyframe editor) ---- + // Gated on a skeletal animation OR any mesh/vertex animation + // (morph + Alembic vertex clips surface as AnimationStates, which + // PropertiesPanelController.hasAnimations picks up). Without the + // hasAnimations clause the dope sheet / curve editor never appeared + // for a morph-only mesh, so a freshly-authored morph target had no + // timeline to key/scrub — even though allMorphRows() enumerates it. CollapsibleSection { title: "Animation Control" sectionVisible: root.modeToolSectionVisible( EditorModeController.AnimationMode, - AnimationControlController.hasAnimation) + AnimationControlController.hasAnimation + || PropertiesPanelController.hasAnimations) expanded: false Component.onCompleted: content = animControlComponent @@ -7599,6 +7625,12 @@ Rectangle { function onSelectionChanged() { refreshAnimData() } function onAnimationStateChanged() { refreshAnimData() } } + // Morph clips (create/delete/rename) are mesh animations that show + // in this list too — refresh when they change. + Connections { + target: MorphAnimationManager + function onMorphClipsChanged() { refreshAnimData() } + } // ── Generate from text (#411, experimental) ────────────────────── // Lives in the Animations group and shows for any skeleton-bearing @@ -8254,342 +8286,815 @@ 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 + // ---- Vertex Morph Animation (Edit Mode authoring) ---- + // Blend-shape / morph-target authoring lives in Edit Mode because you + // capture the CURRENT edited vertex positions (vs the bind pose) as a new + // target. Playback/weight tuning of existing targets also works here. + Component { + id: vertexMorphComponent + + // ---- 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 + // Always visible inside this section — the section itself is already + // gated on Edit Mode (see the CollapsibleSection). Showing it + // unconditionally is what lets the user create the FIRST clip + + // target on a mesh that has none yet (the chicken-and-egg fix). + 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 + + // 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: "" + + // Shared drag-reorder state (one drag at a time). Rows read these + // so the insertion indicator can render on the LANDING row even + // though the drag gesture is owned by the dragged row's grip. + property int dragFromIndex: -1 // row being dragged (-1 = idle) + property int dragToIndex: -1 // proposed landing index + // 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 + } + } + + // One-line explainer so the two concepts don't get conflated: + // SHAPES are the sculpted deformations; CLIPS animate their + // weights over time. Authored top-to-bottom (shapes first). + Text { + width: parent.width + wrapMode: Text.Wrap + color: PropertiesPanelController.textColor + opacity: 0.6 + font.pixelSize: 9 + font.italic: true + text: "Shapes = sculpted poses. Clips = animate shape weights over time." + } + + // ── Section 1: SHAPES (morph targets) ─────────────────────── + // RowLayout (not a plain Row with a magic-number spacer): + // the old `Item { width: parent.width - 320 }` went negative + // on a narrow Inspector, overlapping the title with the + // buttons. Here the title takes the flexible space and + // elides; the buttons keep their intrinsic size at the right. + RowLayout { + width: parent.width spacing: 4 + Text { + Layout.fillWidth: true + elide: Text.ElideRight + text: "1 · Shapes (" + morphCol.targetCount + ")" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + // Sculpt toggle — begins/ends a non-destructive morph + // sculpt session. While active, vertex edits are treated as + // shaping a new target and the BASE mesh is restored when + // the session ends (Blender shape-key behaviour). This is + // the entry point: you can't "+ Add" without first sculpting. + Rectangle { + id: sculptBtn + property bool active: EditModeController.morphSculptActive + Layout.preferredWidth: 60 + Layout.preferredHeight: 20 + radius: 3 + opacity: EditModeController.editModeActive ? 1.0 : 0.45 + color: active + ? PropertiesPanelController.highlightColor + : (sculptMa.containsMouse && EditModeController.editModeActive + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor) + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: sculptBtn.active ? "Done" : "Sculpt" + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: sculptMa + anchors.fill: parent + hoverEnabled: true + enabled: EditModeController.editModeActive + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: { + if (EditModeController.morphSculptActive) + EditModeController.endMorphSculpt() + else + EditModeController.beginMorphSculpt() + } + ToolTip.visible: containsMouse && !enabled + ToolTip.text: "Enter Edit Mode (Tab) first." + } + } + // Add captured shape — stores the current sculpt as a target + // (delta vs the base captured when the session began). Only + // available during an active sculpt session. + Rectangle { + id: addBtn + property bool canAddFromEdit: EditModeController.morphSculptActive + Layout.preferredWidth: 56 + Layout.preferredHeight: 20 + radius: 3 + opacity: canAddFromEdit ? 1.0 : 0.45 + color: addMa.containsMouse && canAddFromEdit + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: "+ Add…" + color: PropertiesPanelController.textColor + font.pixelSize: 9 + } + MouseArea { + id: addMa + anchors.fill: parent + hoverEnabled: true + enabled: addBtn.canAddFromEdit + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: { + addNameField.text = "" + addError.text = "" + addNamePopup.open() + } + ToolTip.visible: containsMouse + ToolTip.text: enabled + ? "Save the current sculpt as a new shape (morph target)" + : "Click “Sculpt” first, then move vertices to shape the target." + } + } + // Reset all: walks every target and sets weight to 0. + Rectangle { + Layout.preferredWidth: 60 + Layout.preferredHeight: 20 + radius: 3 + color: resetMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + 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) + } + } + } + } - // 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 - // `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 + // ── Section 2: ANIMATION CLIPS ────────────────────────────── + // Section header, shown only once shapes exist (clips animate + // shapes, so they're meaningless without any). + Text { + width: parent.width + visible: morphCol.targetCount > 0 + topPadding: 6 + text: "2 · Animation clips" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + } + // Morph-clip selector (smile / angry / surprised …). The targets + // below are SHARED across clips; each clip keyframes them to + // different values over time. Keying (◈ / dope sheet) writes to + // the active clip. Each clip exports as its own glTF animation. + RowLayout { + id: clipRow + width: parent.width + spacing: 4 + // Clips animate existing targets, so the selector only makes + // sense once at least one target exists. (Also matches the + // backend guard that rejects clip creation with no targets.) + visible: morphCol.targetCount > 0 + // Bumped to force re-read of morphClips() when it changes. + property int clipTick: 0 Connections { target: MorphAnimationManager - function onMorphTargetsChanged() { - morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] - morphCol.weightTick = morphCol.weightTick + 1 + // Refresh the clip list + selection when clips change + // (create/delete/rename) or the active clip is switched. + function onMorphClipsChanged() { + clipRow.clipTick++ + clipCombo.syncIndex() + } + } + Text { + text: "Clip:" + color: PropertiesPanelController.textColor + font.pixelSize: 10 + Layout.alignment: Qt.AlignVCenter + } + ThemedComboBox { + id: clipCombo + Layout.fillWidth: true + Layout.preferredHeight: 20 + font.pixelSize: 10 + // clipTick in the expression forces re-evaluation when + // the clip list changes. + property var clips: (clipRow.clipTick, + MorphAnimationManager.morphClips()) + model: clips.length > 0 ? clips + : [MorphAnimationManager.activeMorphClip] + Component.onCompleted: syncIndex() + onClipsChanged: syncIndex() + function syncIndex() { + var a = MorphAnimationManager.activeMorphClip + var i = model.indexOf(a) + currentIndex = i >= 0 ? i : 0 + } + onActivated: { + if (currentIndex >= 0 && currentIndex < model.length) + MorphAnimationManager.activeMorphClip = model[currentIndex] } - function onMorphWeightChanged(entity, name, weight) { - morphCol.weightTick = morphCol.weightTick + 1 + } + // New clip + Rectangle { + Layout.preferredWidth: 40; Layout.preferredHeight: 20; radius: 3 + color: newClipMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "+ New" + color: PropertiesPanelController.textColor; font.pixelSize: 9 } + MouseArea { + id: newClipMa + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { newClipField.text = ""; newClipError.text = "" + newClipPopup.open() } + ToolTip.visible: containsMouse + ToolTip.text: "New animation clip (e.g. smile) that keyframes the shapes' weights over time" } } + // Delete active clip + Rectangle { + Layout.preferredWidth: 20; Layout.preferredHeight: 20; radius: 3 + visible: clipCombo.clips.length > 0 + color: delClipMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" + Text { anchors.centerIn: parent; text: "×" + color: PropertiesPanelController.textColor + font.pixelSize: 12; font.bold: true } + MouseArea { + id: delClipMa + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MorphAnimationManager.deleteMorphClip( + MorphAnimationManager.activeMorphClip) + ToolTip.visible: containsMouse + ToolTip.text: "Delete the active morph clip (targets are kept)" + } + } + } - Row { - spacing: 4 - width: parent.width + // New-clip name popup. + Popup { + id: newClipPopup + modal: true; focus: true; width: 240; padding: 10 + onOpened: newClipField.forceActiveFocus() + background: Rectangle { + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1; radius: 4 + } + contentItem: Column { + spacing: 6 + Text { text: "New morph clip name (e.g. smile, angry):" + color: PropertiesPanelController.textColor; font.pixelSize: 11 } + TextField { + id: newClipField + width: 220; font.pixelSize: 11 + color: PropertiesPanelController.textColor + selectByMouse: true + placeholderText: "clip name" + background: Rectangle { + color: PropertiesPanelController.inputColor + border.color: newClipField.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1; radius: 3 + } + onAccepted: newClipConfirm.confirm() + onTextChanged: newClipError.text = "" + } Text { - text: "Morph Targets (" + morphCol.targetCount + ")" + id: newClipError + text: ""; visible: text.length > 0 + color: "#d65d5d"; font.pixelSize: 10; width: 220; wrapMode: Text.Wrap + } + Row { + spacing: 6 + Rectangle { + width: 60; height: 20; radius: 3 + color: newClipConfirm.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Create" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: newClipConfirm + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + function confirm() { + var n = newClipField.text.trim() + if (n.length === 0) { newClipError.text = "Name cannot be empty."; return } + if (MorphAnimationManager.createMorphClip(n)) + newClipPopup.close() + else + newClipError.text = "Couldn't create: name already in use?" + } + onClicked: confirm() + } + } + Rectangle { + width: 60; height: 20; radius: 3 + color: newClipCancel.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Cancel" + color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: newClipCancel + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: newClipPopup.close() + } + } + } + } + } + + // Inline name-entry popup for "Add from edit…". Themed to + // match the Inspector (panel background + border) rather than + // the default Qt Quick Controls chrome. + Popup { + id: addNamePopup + modal: true + focus: true + width: 240 + padding: 10 + // Focus must be forced AFTER the popup is shown — a + // forceActiveFocus() in the TextField's Component.onCompleted + // runs while the popup is still hidden, so it never sticks + // (the reported "can't type in the input" bug). + onOpened: addNameField.forceActiveFocus() + background: Rectangle { + color: PropertiesPanelController.panelColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 4 + } + contentItem: Column { + spacing: 6 + Text { + text: "New shape name (a sculpted pose, e.g. Smile):" color: PropertiesPanelController.textColor font.pixelSize: 11 - font.bold: true - anchors.verticalCenter: parent.verticalCenter } - Item { width: parent.width - 320; height: 1 } - // Add from current edit — captures the user's current - // edit-mode geometry minus the bind-pose baseline as - // a new morph target. Disabled (greyed out, forbidden - // cursor) when outside edit mode because - // EditableSubMesh::originalPositions is only - // populated by EditModeController and the C++ method - // would return false anyway. - Rectangle { - id: addBtn - property bool canAddFromEdit: EditModeController.editModeActive - width: 56; height: 20; radius: 3 - opacity: canAddFromEdit ? 1.0 : 0.45 - color: addMa.containsMouse && canAddFromEdit - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - anchors.verticalCenter: parent.verticalCenter - Text { - anchors.centerIn: parent - text: "+ Add…" - color: PropertiesPanelController.textColor - font.pixelSize: 9 + TextField { + id: addNameField + width: 220 + font.pixelSize: 11 + color: PropertiesPanelController.textColor + selectByMouse: true + placeholderText: "e.g. Smile, BrowUp…" + placeholderTextColor: Qt.rgba( + PropertiesPanelController.textColor.r, + PropertiesPanelController.textColor.g, + PropertiesPanelController.textColor.b, 0.4) + background: Rectangle { + color: PropertiesPanelController.inputColor + border.color: addNameField.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + radius: 3 } - MouseArea { - id: addMa - anchors.fill: parent - hoverEnabled: true - enabled: addBtn.canAddFromEdit - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor - onClicked: { - addNameField.text = "" - addError.text = "" - addNamePopup.open() + onAccepted: addConfirmMa.confirm() + onTextChanged: addError.text = "" + } + // Inline error: shown when the C++ side rejects + // the request (duplicate name, no vertex moved, + // not in edit mode, …). We deliberately keep the + // popup open so the user can fix the input + // without retyping. + Text { + id: addError + text: "" + visible: text.length > 0 + color: "#d65d5d" + font.pixelSize: 10 + width: 220 + wrapMode: Text.Wrap + } + Row { + spacing: 6 + Rectangle { + width: 60; height: 20; radius: 3 + color: addConfirmMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Save"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: addConfirmMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + function confirm() { + var n = addNameField.text.trim() + if (n.length === 0) { + addError.text = "Name cannot be empty." + return + } + if (!EditModeController.editModeActive) { + addError.text = "Enter Edit Mode (Tab) before saving." + return + } + var ok = MorphAnimationManager.addMorphTargetFromCurrentEdit(n) + if (ok) { + addNamePopup.close() + } else { + // C++ rejected — likely name collision or + // no vertex moved vs the bind baseline. + addError.text = "Couldn't save: name already in use, or no vertex was edited." + } + } + onClicked: confirm() + } + } + Rectangle { + width: 60; height: 20; radius: 3 + color: addCancelMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { anchors.centerIn: parent; text: "Cancel"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } + MouseArea { + id: addCancelMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: addNamePopup.close() } - ToolTip.visible: containsMouse && !enabled - ToolTip.text: "Enter Edit Mode (Tab) to add morph targets from current edit." } } - // Reset all: walks every target and sets weight to 0. + } + } + + // Status / hint line. Guides the non-destructive sculpt flow + // and makes the active session obvious (the base is restored + // when you click Done / leave Edit Mode). + Text { + visible: morphCol.targetCount === 0 + || EditModeController.morphSculptActive + width: parent.width + wrapMode: Text.Wrap + color: EditModeController.morphSculptActive + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.textColor + opacity: EditModeController.morphSculptActive ? 1.0 : 0.7 + font.pixelSize: 10 + text: EditModeController.morphSculptActive + ? "Sculpting: move vertices to shape the target, then “+ Add…”. The base mesh is restored when you click “Done”." + : "Click “Sculpt”, move vertices to shape a target, then “+ Add…”. The base mesh is never changed." + } + + // 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. + // Rows are drag-to-reorder via the grip handle (☰): the grip's + // MouseArea tracks vertical movement and computes the target + // index from the drag distance, then calls + // MorphAnimationManager.moveMorphTargetToIndex (undoable) on + // release. Manual Y-tracking is used rather than Drag/DropArea + // because a layout-managed (Row) child can't reliably drive the + // attached Drag property. Reorder needs the full-list index, so + // the grip is hidden while filtering. + Repeater { + id: morphRepeater + model: morphCol.targets + Item { + id: morphRowItem + width: morphCol.width + visible: morphCol.filter === "" + || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 + height: visible ? 22 : 0 + property string targetName: modelData + property int rowIndex: index + // Row stride = height(22) + Column spacing(4). + readonly property int rowStride: 26 + // Live proposed drop index while dragging (-1 = idle). + property int dropIndex: -1 + // Live vertical offset the dragged row follows the cursor by. + property real dragDy: 0 + + // The row being dragged floats above its neighbours. + readonly property bool isDragged: + morphCol.dragFromIndex === morphRowItem.rowIndex + z: isDragged ? 10 : 0 + + // Dragged-row background (moves with the cursor). Rectangle { - width: 60; height: 20; radius: 3 - color: resetMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor + anchors.fill: parent + y: morphRowItem.dragDy + visible: morphRowItem.isDragged + color: Qt.rgba(PropertiesPanelController.highlightColor.r, + PropertiesPanelController.highlightColor.g, + PropertiesPanelController.highlightColor.b, 0.25) + border.color: PropertiesPanelController.highlightColor + border.width: 1 + radius: 2 + } + + // Insertion indicator: a bright line marking the LANDING + // slot (the shared dragToIndex), rendered on that row. + // Shown on every row EXCEPT the dragged one so the target + // reads clearly even though the list doesn't reflow live. + Rectangle { + visible: morphCol.dragFromIndex >= 0 + && !morphRowItem.isDragged + && morphCol.dragToIndex === morphRowItem.rowIndex + width: parent.width; height: 2; radius: 1 + color: PropertiesPanelController.highlightColor + // Bottom edge when dropping below the origin ("lands + // after this row"), top edge when dropping above. + y: (morphCol.dragToIndex > morphCol.dragFromIndex) + ? parent.height - height : 0 + } + + Row { + anchors.fill: parent + spacing: 4 + // The content follows the cursor vertically while dragging. + y: morphRowItem.dragDy + + // Drag handle (☰) — the only drag-initiating surface, so + // the slider / rename / buttons keep their own gestures. + Item { + id: morphGrip + width: 14; height: 22 + visible: morphCol.filter === "" && morphCol.targetCount > 1 anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent - text: "Reset all" + text: "☰" color: PropertiesPanelController.textColor - font.pixelSize: 9 + opacity: gripMa.dragging ? 1.0 : 0.6 + font.pixelSize: 11 } MouseArea { - id: resetMa + id: gripMa anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - for (var i = 0; i < morphCol.targets.length; ++i) - MorphAnimationManager.setWeightForSelection(morphCol.targets[i], 0) + cursorShape: gripMa.dragging ? Qt.ClosedHandCursor + : Qt.OpenHandCursor + preventStealing: true + property bool dragging: false + property real pressY: 0 + property int startIndex: 0 + onPressed: function(mouse) { + pressY = mouse.y + startIndex = morphRowItem.rowIndex + dragging = true + morphRowItem.dragDy = 0 + morphCol.dragFromIndex = morphRowItem.rowIndex + morphCol.dragToIndex = morphRowItem.rowIndex + } + onPositionChanged: function(mouse) { + if (!dragging) return + var dy = (mouse.y - pressY) + // Move the row visual with the cursor. + morphRowItem.dragDy = dy + var shift = Math.round(dy / morphRowItem.rowStride) + var target = morphRowItem.rowIndex + shift + if (target < 0) target = 0 + if (target > morphCol.targetCount - 1) + target = morphCol.targetCount - 1 + morphCol.dragToIndex = target + } + onReleased: function(mouse) { + if (!dragging) return + dragging = false + var target = morphCol.dragToIndex + var from = morphCol.dragFromIndex + morphCol.dragFromIndex = -1 + morphCol.dragToIndex = -1 + morphRowItem.dragDy = 0 + if (target >= 0 && target !== from) + MorphAnimationManager.moveMorphTargetToIndex( + morphRowItem.targetName, target) + } + onCanceled: { + dragging = false + morphCol.dragFromIndex = -1 + morphCol.dragToIndex = -1 + morphRowItem.dragDy = 0 } } } - } - // Inline name-entry popup for "Add from edit…". Kept - // simple (no styled component) so a misbehaving custom - // dialog can't break the rest of the panel — Popup is - // a built-in Qt Quick Controls primitive with no - // singleton dependencies. - Popup { - id: addNamePopup - modal: true - focus: true - width: 240 - contentItem: Column { - spacing: 6 - Text { - text: "New morph target name:" - color: PropertiesPanelController.textColor - font.pixelSize: 11 + // Name — double-click to rename in place, + // matching the per-animation rename UX above. + Text { + id: morphNameText + visible: !morphNameEdit.visible + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 10 + width: morphCol.filter === "" && morphCol.targetCount > 1 ? 106 : 120 + // Middle ellipsis: morph names often share long + // prefixes (LeftEyebrow… / LeftEyeBlink…) AND + // meaningful suffixes (_01, _L), so elide the middle. + elide: Text.ElideMiddle + anchors.verticalCenter: parent.verticalCenter + ToolTip.visible: nameHover.hovered && truncated + ToolTip.text: modelData + HoverHandler { id: nameHover } + MouseArea { + anchors.fill: parent + onDoubleClicked: { + morphNameEdit.text = modelData + morphNameEdit.visible = true + morphNameEdit.forceActiveFocus() + morphNameEdit.selectAll() + } } - TextField { - id: addNameField - width: 220 - font.pixelSize: 11 - onAccepted: addConfirmMa.confirm() - onTextChanged: addError.text = "" - Component.onCompleted: forceActiveFocus() + } + TextInput { + id: morphNameEdit + visible: false + width: 120 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + selectByMouse: true + // Set by `Keys.onEscapePressed`; checked in + // `onEditingFinished` so that hiding the + // input on Escape (which causes focus loss + // and fires `editingFinished`) doesn't + // accidentally commit the rename. + property bool cancelled: false + Rectangle { + anchors.fill: parent + anchors.margins: -2 + z: -1 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.highlightColor + border.width: 1 + radius: 2 } - // Inline error: shown when the C++ side rejects - // the request (duplicate name, no vertex moved, - // not in edit mode, …). We deliberately keep the - // popup open so the user can fix the input - // without retyping. - Text { - id: addError - text: "" - visible: text.length > 0 - color: "#d65d5d" - font.pixelSize: 10 - width: 220 - wrapMode: Text.Wrap - } - Row { - spacing: 6 - Rectangle { - width: 60; height: 20; radius: 3 - color: addConfirmMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - Text { anchors.centerIn: parent; text: "Save"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } - MouseArea { - id: addConfirmMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - function confirm() { - var n = addNameField.text.trim() - if (n.length === 0) { - addError.text = "Name cannot be empty." - return - } - if (!EditModeController.editModeActive) { - addError.text = "Enter Edit Mode (Tab) before saving." - return - } - var ok = MorphAnimationManager.addMorphTargetFromCurrentEdit(n) - if (ok) { - addNamePopup.close() - } else { - // C++ rejected — likely name collision or - // no vertex moved vs the bind baseline. - addError.text = "Couldn't save: name already in use, or no vertex was edited." - } - } - onClicked: confirm() - } - } - Rectangle { - width: 60; height: 20; radius: 3 - color: addCancelMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor - Text { anchors.centerIn: parent; text: "Cancel"; color: PropertiesPanelController.textColor; font.pixelSize: 10 } - MouseArea { - id: addCancelMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: addNamePopup.close() - } - } + onEditingFinished: { + if (cancelled) { cancelled = false; visible = false; return } + var trimmed = text.trim() + if (trimmed.length > 0 && trimmed !== modelData) + MorphAnimationManager.renameMorphTarget(modelData, trimmed) + visible = false } + Keys.onEscapePressed: { cancelled = true; visible = false } } - } - - // 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 + Slider { + id: weightSlider + from: 0; to: 1; stepSize: 0.01 + // grip(14) + name(106) + weight(36) + key(16) + + // delete(18) + row spacings ≈ 220 reserved. + width: parent.width - 220 + // 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) + } + Text { + text: weightSlider.value.toFixed(2) + color: PropertiesPanelController.textColor + font.pixelSize: 10 + width: 36 + anchors.verticalCenter: parent.verticalCenter + } + // Key weight at playhead (◈) — records the current + // weight at the timeline playhead time as a keyframe on + // the shared "MorphAnim" weight clip (Slice 2 #519). + // Diamonds appear on the dope sheet; the clip plays + + // exports to glTF as a morph-weights animation. Hidden + // while filtering (keeps the row compact + the reorder + // arrows already hide then). + Rectangle { + width: 16; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter visible: morphCol.filter === "" - || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 - height: visible ? 22 : 0 - - // Name — double-click to rename in place, - // matching the per-animation rename UX above. + color: keyMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" Text { - id: morphNameText - visible: !morphNameEdit.visible - text: modelData - color: PropertiesPanelController.textColor - font.pixelSize: 10 - width: 120 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - MouseArea { - anchors.fill: parent - onDoubleClicked: { - morphNameEdit.text = modelData - morphNameEdit.visible = true - morphNameEdit.forceActiveFocus() - morphNameEdit.selectAll() - } - } + anchors.centerIn: parent + text: "◈" + color: "#88ccff" // matches the dope-sheet morph diamonds + font.pixelSize: 11 } - TextInput { - id: morphNameEdit - visible: false - width: 120 - color: PropertiesPanelController.textColor - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - selectByMouse: true - // Set by `Keys.onEscapePressed`; checked in - // `onEditingFinished` so that hiding the - // input on Escape (which causes focus loss - // and fires `editingFinished`) doesn't - // accidentally commit the rename. - property bool cancelled: false - Rectangle { - anchors.fill: parent - anchors.margins: -2 - z: -1 - color: PropertiesPanelController.inputColor - border.color: PropertiesPanelController.highlightColor - border.width: 1 - radius: 2 - } - onEditingFinished: { - if (cancelled) { cancelled = false; visible = false; return } - var trimmed = text.trim() - if (trimmed.length > 0 && trimmed !== modelData) - MorphAnimationManager.renameMorphTarget(modelData, trimmed) - visible = false + MouseArea { + id: keyMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + var t = AnimationControlController.sliderValue / 1000.0 + MorphAnimationManager.setMorphWeightKeyframe( + modelData, t, weightSlider.value) + // Make the weight clip the active, playable + // animation so scrubbing/playing shows the + // keyed weights immediately (otherwise the + // key exists but nothing drives it). + MorphAnimationManager.activateWeightClip() } - Keys.onEscapePressed: { cancelled = true; visible = false } - } - Slider { - id: morphWeightSlider - from: 0; to: 1; stepSize: 0.01 - width: parent.width - 222 - // 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) + ToolTip.visible: containsMouse + ToolTip.text: "Key this weight at the timeline playhead" } + } + // Delete (×) — drops the pose + animation + // through DeleteMorphTargetCommand so Ctrl+Z + // restores it. + Rectangle { + width: 18; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + color: morphDelMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" Text { - text: morphWeightSlider.value.toFixed(2) + anchors.centerIn: parent + text: "×" color: PropertiesPanelController.textColor - font.pixelSize: 10 - width: 36 - anchors.verticalCenter: parent.verticalCenter + font.pixelSize: 12 + font.bold: true } - // Delete (×) — drops the pose + animation - // through DeleteMorphTargetCommand so Ctrl+Z - // restores it. - Rectangle { - width: 18; height: 18; radius: 3 - anchors.verticalCenter: parent.verticalCenter - color: morphDelMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : "transparent" - Text { - anchors.centerIn: parent - text: "×" - color: PropertiesPanelController.textColor - font.pixelSize: 12 - font.bold: true - } - MouseArea { - id: morphDelMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: MorphAnimationManager.deleteMorphTarget(modelData) - } + MouseArea { + id: morphDelMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: MorphAnimationManager.deleteMorphTarget(modelData) } } - } + } // Row + } // morphRowItem (drag/drop wrapper) } } } diff --git a/src/AlembicImporter.cpp b/src/AlembicImporter.cpp new file mode 100644 index 000000000..3da1f8a15 --- /dev/null +++ b/src/AlembicImporter.cpp @@ -0,0 +1,406 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "AlembicImporter.h" + +#include "Manager.h" +#include "SentryReporter.h" + +#include + +#ifdef ENABLE_ALEMBIC +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#endif + +namespace AlembicImporter { + +bool available() +{ +#ifdef ENABLE_ALEMBIC + return true; +#else + return false; +#endif +} + +#ifndef ENABLE_ALEMBIC + +ReadResult readFrameSet(const QString&, int) +{ + ReadResult r; + r.error = QStringLiteral( + "Alembic import needs a build with -DENABLE_ALEMBIC (Alembic + Imath " + "are not compiled in)."); + return r; +} + +Ogre::SceneNode* importToScene(const QString&, QString* error) +{ + if (error) + *error = QStringLiteral( + "Alembic import needs a build with -DENABLE_ALEMBIC."); + return nullptr; +} + +InfoResult readInfo(const QString&) +{ + InfoResult r; + r.error = QStringLiteral("Alembic info needs a build with -DENABLE_ALEMBIC."); + return r; +} + +#else // ENABLE_ALEMBIC + +using namespace Alembic::AbcGeom; + +namespace { + +// Depth-first search for the first IPolyMesh in the archive tree. +IPolyMesh findFirstPolyMesh(IObject obj) +{ + for (size_t i = 0; i < obj.getNumChildren(); ++i) { + IObject child = obj.getChild(i); + if (IPolyMesh::matches(child.getHeader())) + return IPolyMesh(child, kWrapExisting); + IPolyMesh found = findFirstPolyMesh(child); + if (found.valid()) + return found; + } + return IPolyMesh(); +} + +} // namespace + +ReadResult readFrameSet(const QString& path, int maxFrames) +{ + ReadResult r; + QFileInfo fi(path); + if (!fi.exists()) { + r.error = QStringLiteral("Alembic file not found: %1").arg(path); + return r; + } + + try { + Alembic::AbcCoreFactory::IFactory factory; + IArchive archive = factory.getArchive(path.toStdString()); + if (!archive.valid()) { + r.error = QStringLiteral("Not a readable Alembic archive: %1").arg(path); + return r; + } + + IPolyMesh mesh = findFirstPolyMesh(archive.getTop()); + if (!mesh.valid()) { + r.error = QStringLiteral("No polygon mesh found in %1").arg(fi.fileName()); + return r; + } + r.meshName = QString::fromStdString(mesh.getName()); + + IPolyMeshSchema& schema = mesh.getSchema(); + const size_t numSamples = schema.getNumSamples(); + if (numSamples == 0) { + r.error = QStringLiteral("Alembic mesh '%1' has no samples").arg(r.meshName); + return r; + } + + Alembic::AbcCoreAbstract::TimeSamplingPtr ts = schema.getTimeSampling(); + + // Topology (vertex count) comes from the first sample; VAT_POSE needs a + // fixed base, so reject a cache whose vertex count changes over time. + IPolyMeshSchema::Sample first; + schema.get(first, ISampleSelector(static_cast(0))); + const size_t baseVerts = first.getPositions()->size(); + if (baseVerts == 0) { + r.error = QStringLiteral("Alembic mesh '%1' has zero vertices").arg(r.meshName); + return r; + } + + size_t decodeCount = numSamples; + if (maxFrames > 0 && static_cast(maxFrames) < decodeCount) + decodeCount = static_cast(maxFrames); + r.totalFrames = static_cast(numSamples); + r.truncated = (decodeCount < numSamples); + + VertexAnimationManager::FrameSet& fs = r.frames; + fs.vertexCount = static_cast(baseVerts); + // fps from the sample spacing (uniform sampling → constant dt). + const double dt = (numSamples > 1) + ? (ts->getSampleTime(1) - ts->getSampleTime(0)) + : (1.0 / 30.0); + fs.fps = (dt > 1e-9) ? static_cast(std::lround(1.0 / dt)) : 30; + if (fs.fps <= 0) fs.fps = 30; + + float mn[3] = { std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max() }; + float mx[3] = { -std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max() }; + + for (size_t s = 0; s < decodeCount; ++s) { + IPolyMeshSchema::Sample samp; + schema.get(samp, ISampleSelector(static_cast(s))); + Abc::P3fArraySamplePtr P = samp.getPositions(); + if (!P || P->size() != baseVerts) { + r.error = QStringLiteral( + "Alembic mesh '%1' changes vertex count between frames " + "(frame %2: %3 vs %4) — not a fixed-topology vertex cache") + .arg(r.meshName).arg(s) + .arg(P ? P->size() : 0).arg(baseVerts); + r.frames = {}; + return r; + } + VertexAnimationManager::FrameData fd; + fd.time = static_cast(ts->getSampleTime(s) - ts->getSampleTime(0)); + fd.positions.resize(baseVerts * 3); + for (size_t v = 0; v < baseVerts; ++v) { + const Imath::V3f& p = (*P)[v]; + fd.positions[v * 3 + 0] = p.x; + fd.positions[v * 3 + 1] = p.y; + fd.positions[v * 3 + 2] = p.z; + mn[0] = std::min(mn[0], p.x); mx[0] = std::max(mx[0], p.x); + mn[1] = std::min(mn[1], p.y); mx[1] = std::max(mx[1], p.y); + mn[2] = std::min(mn[2], p.z); mx[2] = std::max(mx[2], p.z); + } + fs.frames.push_back(std::move(fd)); + } + fs.aabb = { mn[0], mn[1], mn[2], mx[0], mx[1], mx[2] }; + + r.ok = fs.ok(); + if (!r.ok) + r.error = QStringLiteral( + "Alembic mesh '%1' produced fewer than 2 frames — nothing to animate") + .arg(r.meshName); + return r; + } catch (const std::exception& e) { + r.error = QStringLiteral("Alembic read error: %1").arg(QString::fromUtf8(e.what())); + r.frames = {}; + return r; + } +} + +InfoResult readInfo(const QString& path) +{ + InfoResult r; + QFileInfo fi(path); + if (!fi.exists()) { + r.error = QStringLiteral("Alembic file not found: %1").arg(path); + return r; + } + try { + Alembic::AbcCoreFactory::IFactory factory; + IArchive archive = factory.getArchive(path.toStdString()); + if (!archive.valid()) { + r.error = QStringLiteral("Not a readable Alembic archive: %1").arg(path); + return r; + } + IPolyMesh mesh = findFirstPolyMesh(archive.getTop()); + if (!mesh.valid()) { + r.error = QStringLiteral("No polygon mesh found in %1").arg(fi.fileName()); + return r; + } + IPolyMeshSchema& schema = mesh.getSchema(); + const size_t numSamples = schema.getNumSamples(); + // Accessing sample 0 on a zero-sample schema is UB in the Alembic API, + // so bail before schema.get() (mirrors readFrameSet's guard). + if (numSamples == 0) { + r.error = QStringLiteral("Alembic mesh '%1' has no samples") + .arg(QString::fromStdString(mesh.getName())); + return r; + } + Alembic::AbcCoreAbstract::TimeSamplingPtr ts = schema.getTimeSampling(); + IPolyMeshSchema::Sample first; + schema.get(first, ISampleSelector(static_cast(0))); + + r.meshName = QString::fromStdString(mesh.getName()); + r.frameCount = static_cast(numSamples); + r.vertexCount = first.getPositions() ? static_cast(first.getPositions()->size()) : 0; + // Sum triangles across n-gon faces (fan triangulation = n-2 per face). + int tris = 0; + if (auto fc = first.getFaceCounts()) + for (size_t f = 0; f < fc->size(); ++f) + tris += std::max(0, (*fc)[f] - 2); + r.faceCount = tris; + const double dt = (numSamples > 1) + ? (ts->getSampleTime(1) - ts->getSampleTime(0)) : (1.0 / 30.0); + r.fps = (dt > 1e-9) ? static_cast(std::lround(1.0 / dt)) : 30; + if (r.fps <= 0) r.fps = 30; + r.durationSec = (numSamples > 1) + ? static_cast(ts->getSampleTime(numSamples - 1) - ts->getSampleTime(0)) + : 0.0f; + r.storage = (VertexAnimationManager::sampleHeuristic(r.frameCount) + == VertexAnimationManager::Storage::Poses) ? "poses" : "stream"; + r.ok = (r.vertexCount > 0 && r.frameCount > 0); + if (!r.ok) + r.error = QStringLiteral("Alembic mesh '%1' has no usable samples").arg(r.meshName); + return r; + } catch (const std::exception& e) { + r.error = QStringLiteral("Alembic info error: %1").arg(QString::fromUtf8(e.what())); + return r; + } +} + +// Build a static base Ogre::Mesh (frame-0 topology + positions) with POSITION + +// NORMAL, non-shared submesh 0, so buildClipFromFrames' VAT_POSE poses target +// submesh handle 1 — matching the morph/vertex-anim convention. +static Ogre::MeshPtr buildBaseMesh(const QString& name, + const QString& abcPath, + const VertexAnimationManager::FrameSet& fs) +{ + // Re-read topology (face indices) from the archive's first sample. + std::vector indices; + try { + Alembic::AbcCoreFactory::IFactory factory; + IArchive archive = factory.getArchive(abcPath.toStdString()); + IPolyMesh mesh = findFirstPolyMesh(archive.getTop()); + IPolyMeshSchema::Sample first; + mesh.getSchema().get(first, ISampleSelector(static_cast(0))); + Abc::Int32ArraySamplePtr faceIdx = first.getFaceIndices(); + Abc::Int32ArraySamplePtr faceCnt = first.getFaceCounts(); + // Fan-triangulate each n-gon face (Alembic faces are arbitrary polygons). + size_t cursor = 0; + for (size_t f = 0; f < faceCnt->size(); ++f) { + const int n = (*faceCnt)[f]; + for (int t = 1; t + 1 < n; ++t) { + indices.push_back(static_cast((*faceIdx)[cursor])); + indices.push_back(static_cast((*faceIdx)[cursor + t])); + indices.push_back(static_cast((*faceIdx)[cursor + t + 1])); + } + cursor += static_cast(n); + } + } catch (const std::exception&) { + return Ogre::MeshPtr(); + } + if (indices.empty()) return Ogre::MeshPtr(); + + const int vcount = fs.vertexCount; + const std::vector& pos0 = fs.frames.front().positions; + + auto& mm = Ogre::MeshManager::getSingleton(); + if (mm.resourceExists(name.toStdString())) + mm.remove(name.toStdString()); + Ogre::MeshPtr om = mm.createManual( + name.toStdString(), Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* sub = om->createSubMesh(); + sub->useSharedVertices = false; + sub->vertexData = new Ogre::VertexData(); + sub->vertexData->vertexCount = static_cast(vcount); + auto* decl = sub->vertexData->vertexDeclaration; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), vcount, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + std::vector vdata(static_cast(vcount) * 6, 0.0f); + for (int v = 0; v < vcount; ++v) { + vdata[v * 6 + 0] = pos0[v * 3 + 0]; + vdata[v * 6 + 1] = pos0[v * 3 + 1]; + vdata[v * 6 + 2] = pos0[v * 3 + 2]; + vdata[v * 6 + 5] = 1.0f; // placeholder +Z normal (recomputed by shading) + } + vbuf->writeData(0, vdata.size() * sizeof(float), vdata.data()); + sub->vertexData->vertexBufferBinding->setBinding(0, vbuf); + + const bool use32 = vcount > 65535; + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + use32 ? Ogre::HardwareIndexBuffer::IT_32BIT : Ogre::HardwareIndexBuffer::IT_16BIT, + indices.size(), Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + if (use32) { + ibuf->writeData(0, indices.size() * sizeof(uint32_t), indices.data()); + } else { + std::vector i16(indices.size()); + for (size_t i = 0; i < indices.size(); ++i) + i16[i] = static_cast(indices[i]); + ibuf->writeData(0, i16.size() * sizeof(uint16_t), i16.data()); + } + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = indices.size(); + + Ogre::AxisAlignedBox aabb(fs.aabb[0], fs.aabb[1], fs.aabb[2], + fs.aabb[3], fs.aabb[4], fs.aabb[5]); + om->_setBounds(aabb); + om->_setBoundingSphereRadius(0.5f * aabb.getSize().length()); + om->load(); + return om; +} + +Ogre::SceneNode* importToScene(const QString& path, QString* error) +{ + auto fail = [&](const QString& m) -> Ogre::SceneNode* { + if (error) *error = m; + return nullptr; + }; + + SentryReporter::addBreadcrumb(QStringLiteral("scene.anim.vertex_anim"), + QStringLiteral("import Alembic %1") + .arg(QFileInfo(path).fileName())); + + // Decode heuristic-capped: pose storage tops out at 32 frames, but we still + // decode all here (streaming is B3). Cap conservatively so a pathological + // multi-thousand-frame cache doesn't hang the import before B3 lands. + ReadResult rr = readFrameSet(path, /*maxFrames=*/512); + if (!rr.ok) + return fail(rr.error); + + // No silent caps (CLAUDE.md): VAT_POSE holds every frame resident as an + // Ogre::Pose, so a multi-thousand-frame cache would balloon GPU memory. + // We cap the decode at 512 frames; say so loudly when it bites. True + // disk-streaming (swapping vertex buffers per frame) is future work. + if (rr.truncated) { + Ogre::LogManager::getSingleton().logWarning( + ("Alembic '" + QFileInfo(path).fileName().toStdString() + "': imported " + + std::to_string(rr.frames.frames.size()) + " of " + + std::to_string(rr.totalFrames) + + " frames (capped at 512 — VAT_POSE holds every frame resident).").c_str()); + } + + auto* mgr = Manager::getSingletonPtr(); + if (!mgr || !mgr->getSceneMgr()) + return fail(QStringLiteral("No active scene to import into.")); + + const QString base = QFileInfo(path).completeBaseName(); + const QString meshName = base + QStringLiteral("_abc"); + Ogre::MeshPtr om = buildBaseMesh(meshName, path, rr.frames); + if (!om) + return fail(QStringLiteral("Failed to build base mesh from %1").arg(base)); + + const QString clip = base + QStringLiteral("_cache"); + if (!VertexAnimationManager::buildClipFromFrames(om.get(), clip, rr.frames)) + return fail(QStringLiteral("Failed to build vertex-animation clip.")); + + Ogre::SceneNode* node = mgr->addSceneNode(base + QStringLiteral("_abc_node")); + if (!node) + return fail(QStringLiteral("Failed to create scene node.")); + mgr->createEntity(node, om); + + SentryReporter::addBreadcrumb( + QStringLiteral("scene.anim.vertex_anim"), + QStringLiteral("imported Alembic '%1' — %2 frames, %3 verts") + .arg(rr.meshName).arg(rr.frames.frames.size()).arg(rr.frames.vertexCount)); + return node; +} + +#endif // ENABLE_ALEMBIC + +} // namespace AlembicImporter diff --git a/src/AlembicImporter.h b/src/AlembicImporter.h new file mode 100644 index 000000000..feb518386 --- /dev/null +++ b/src/AlembicImporter.h @@ -0,0 +1,84 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef ALEMBICIMPORTER_H +#define ALEMBICIMPORTER_H + +#include + +#include "VertexAnimationManager.h" + +namespace Ogre { class SceneNode; } + +/** + * @brief Alembic (.abc) vertex-animation reader — Anim Slice B, sub-slice B2. + * + * Decodes a baked per-vertex cache (the first animated IPolyMesh in the archive) + * into a source-agnostic VertexAnimationManager::FrameSet, then B1's + * buildClipFromFrames turns it into an Ogre VAT_POSE clip. Cloth / sims / fluid + * bakes / Houdini + Blender exports. + * + * All Alembic/Imath usage lives in AlembicImporter.cpp behind + * `#ifdef ENABLE_ALEMBIC`. When the build lacks Alembic, `available()` is false + * and the read/import calls fail with a clear "rebuild with -DENABLE_ALEMBIC" + * message — nothing crashes, and the rest of the app is unaffected. + * + * The decode step (readFrameSet) is pure data — no Ogre, no GL — so it's + * unit-testable against a small synthetic .abc fixture under headless CI. + */ +namespace AlembicImporter { + +/// True only when built with ENABLE_ALEMBIC. +bool available(); + +struct ReadResult { + bool ok = false; + QString error; + VertexAnimationManager::FrameSet frames; ///< decoded cache (empty on !ok) + QString meshName; ///< source IPolyMesh name (for the clip) + int totalFrames = 0; ///< frames present in the archive (before any maxFrames cap) + bool truncated = false;///< true when maxFrames dropped frames (frames.size() < totalFrames) +}; + +/// Cheap metadata about an .abc cache WITHOUT decoding every frame's vertex +/// positions (reads the schema header + first sample only). Powers +/// `qtmesh anim .abc --info` and the MCP info surface. +struct InfoResult { + bool ok = false; + QString error; + QString meshName; + int frameCount = 0; + int vertexCount = 0; + int faceCount = 0; + int fps = 30; + float durationSec = 0.0f; + QString storage; ///< "poses" or "stream" per VertexAnimationManager heuristic +}; + +/// Read an .abc's cache metadata without decoding all frames. +InfoResult readInfo(const QString& path); + +/// Decode `path`'s first animated polymesh into a FrameSet. Pure data. The +/// topology (index buffer) is taken from the first sample; positions are read +/// per time-sample. A mesh whose topology changes between frames (variable +/// vertex count) is rejected — VAT_POSE needs a fixed base. `maxFrames` caps +/// how many samples are decoded (0 = all); the streaming path (B3) reads on +/// demand instead. +ReadResult readFrameSet(const QString& path, int maxFrames = 0); + +/// Import `path` into the scene: build a base Ogre::Mesh from the first sample's +/// topology, attach a VAT_POSE clip from the decoded frames, create an entity, +/// and return its SceneNode (nullptr + `error` on failure). Requires an active +/// Ogre scene (GL) — this is the GUI/CLI-viewport entry point. +Ogre::SceneNode* importToScene(const QString& path, QString* error = nullptr); + +} // namespace AlembicImporter + +#endif // ALEMBICIMPORTER_H diff --git a/src/AlembicImporter_test.cpp b/src/AlembicImporter_test.cpp new file mode 100644 index 000000000..9ad714512 --- /dev/null +++ b/src/AlembicImporter_test.cpp @@ -0,0 +1,152 @@ +#include + +#include "AlembicImporter.h" + +#include +#include + +// The decode path only exists in an ENABLE_ALEMBIC build. Without it, assert the +// feature reports unavailable + fails gracefully (no crash) — the contract the +// GUI/CLI rely on for the "rebuild with -DENABLE_ALEMBIC" message. +#ifndef ENABLE_ALEMBIC + +TEST(AlembicImporterStandalone, UnavailableWithoutFlag) { + EXPECT_FALSE(AlembicImporter::available()); + auto rr = AlembicImporter::readFrameSet("/nonexistent.abc"); + EXPECT_FALSE(rr.ok); + EXPECT_FALSE(rr.error.isEmpty()); + QString err; + EXPECT_EQ(AlembicImporter::importToScene("/nonexistent.abc", &err), nullptr); + EXPECT_FALSE(err.isEmpty()); + // readInfo (B3) must also fail-soft without the flag. + auto info = AlembicImporter::readInfo("/nonexistent.abc"); + EXPECT_FALSE(info.ok); + EXPECT_FALSE(info.error.isEmpty()); +} + +#else // ENABLE_ALEMBIC + +#include +#include +#include + +#include +#include + +namespace { + +// Write a tiny 2-frame quad vertex cache to `path`: a unit quad whose 4 verts +// translate +Y over frame 1. Round-trips through the same reader the app uses. +void writeQuadCache(const std::string& path) { + using namespace Alembic::AbcGeom; + OArchive archive(Alembic::AbcCoreOgawa::WriteArchive(), path); + // 30fps uniform time sampling. + const chrono_t dt = 1.0 / 30.0; + TimeSampling tsamp(dt, 0.0); + Alembic::Util::uint32_t tsIdx = archive.addTimeSampling(tsamp); + + OPolyMesh meshObj(OObject(archive, kTop), "quadCache", tsIdx); + OPolyMeshSchema& schema = meshObj.getSchema(); + + // 4 verts, one quad face (count=4). + std::vector p0 = { + {0, 0, 0}, {1, 0, 0}, {1, 0, 1}, {0, 0, 1}}; + std::vector p1 = { + {0, 1, 0}, {1, 1, 0}, {1, 1, 1}, {0, 1, 1}}; + std::vector indices = {0, 1, 2, 3}; + std::vector counts = {4}; + + // First sample carries topology. Build via named args (avoid the + // most-vexing-parse: `Sample s0(TempA(), TempB(), ...)` is read as a + // function declaration). + Abc::V3fArraySample posSample0(p0); + Abc::Int32ArraySample idxSample(indices); + Abc::Int32ArraySample cntSample(counts); + OPolyMeshSchema::Sample s0(posSample0, idxSample, cntSample); + schema.set(s0); + // Second sample: positions only. + Abc::V3fArraySample posSample1(p1); + OPolyMeshSchema::Sample s1; + s1.setPositions(posSample1); + schema.set(s1); + // archive flushes on destruction (end of scope). +} + +} // namespace + +class AlembicImporterTest : public ::testing::Test { +protected: + QString abcPath; + void SetUp() override { + abcPath = QDir::temp().filePath("qtmesh_test_quad.abc"); + QFile::remove(abcPath); + writeQuadCache(abcPath.toStdString()); + } + void TearDown() override { QFile::remove(abcPath); } +}; + +TEST_F(AlembicImporterTest, Available) { + EXPECT_TRUE(AlembicImporter::available()); +} + +TEST_F(AlembicImporterTest, ReadsTwoFrameQuadCache) { + auto rr = AlembicImporter::readFrameSet(abcPath); + ASSERT_TRUE(rr.ok) << rr.error.toStdString(); + EXPECT_EQ(rr.frames.vertexCount, 4); + ASSERT_EQ(rr.frames.frames.size(), 2u); + EXPECT_EQ(rr.frames.fps, 30); + + // Frame 0 = base quad at Y=0; frame 1 = same quad at Y=1. + const auto& f0 = rr.frames.frames[0].positions; + const auto& f1 = rr.frames.frames[1].positions; + ASSERT_EQ(f0.size(), 12u); + for (int v = 0; v < 4; ++v) { + EXPECT_NEAR(f0[v * 3 + 1], 0.0f, 1e-5f); // frame 0 Y == 0 + EXPECT_NEAR(f1[v * 3 + 1], 1.0f, 1e-5f); // frame 1 Y == 1 + } + // Times: frame 1 is 1/30 s after frame 0. + EXPECT_NEAR(rr.frames.frames[0].time, 0.0f, 1e-5f); + EXPECT_NEAR(rr.frames.frames[1].time, 1.0f / 30.0f, 1e-4f); + // AABB spans Y 0..1. + EXPECT_NEAR(rr.frames.aabb[1], 0.0f, 1e-5f); + EXPECT_NEAR(rr.frames.aabb[4], 1.0f, 1e-5f); +} + +TEST_F(AlembicImporterTest, MissingFileFailsGracefully) { + auto rr = AlembicImporter::readFrameSet("/no/such/file.abc"); + EXPECT_FALSE(rr.ok); + EXPECT_FALSE(rr.error.isEmpty()); +} + +// B3: readInfo returns the same metadata as a full decode but without reading +// every frame's positions. On the 2-frame quad it must match readFrameSet. +TEST_F(AlembicImporterTest, ReadInfoMatchesDecode) { + auto info = AlembicImporter::readInfo(abcPath); + ASSERT_TRUE(info.ok) << info.error.toStdString(); + EXPECT_EQ(info.frameCount, 2); + EXPECT_EQ(info.vertexCount, 4); + EXPECT_EQ(info.fps, 30); + // One quad → 2 triangles. + EXPECT_EQ(info.faceCount, 2); + // 2 frames is well under the pose/stream threshold (32) → "poses". + EXPECT_EQ(info.storage, QStringLiteral("poses")); + // duration = (frameCount - 1) / fps for uniform sampling. + EXPECT_NEAR(info.durationSec, 1.0f / 30.0f, 1e-4f); +} + +// B3: maxFrames caps the decode and flags truncation (no silent cap). +TEST_F(AlembicImporterTest, MaxFramesTruncates) { + auto rr = AlembicImporter::readFrameSet(abcPath, /*maxFrames=*/1); + ASSERT_TRUE(rr.ok) << rr.error.toStdString(); + EXPECT_EQ(rr.frames.frames.size(), 1u); + EXPECT_EQ(rr.totalFrames, 2); + EXPECT_TRUE(rr.truncated); + + // maxFrames >= total (or 0) must not flag truncation. + auto full = AlembicImporter::readFrameSet(abcPath, /*maxFrames=*/0); + ASSERT_TRUE(full.ok); + EXPECT_EQ(full.totalFrames, 2); + EXPECT_FALSE(full.truncated); +} + +#endif // ENABLE_ALEMBIC diff --git a/src/AnimationControlController.cpp b/src/AnimationControlController.cpp index 95ee37a34..3baeba41c 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1,6 +1,7 @@ #include "AnimationControlController.h" #include "GamificationManager.h" #include "PropertiesPanelController.h" +#include "MorphAnimationManager.h" #include "SelectionSet.h" #include "Manager.h" #include "SentryReporter.h" @@ -230,6 +231,13 @@ void AnimationControlController::selectAnimation(const QString& entityName, cons if (m_selectedSkeleton && m_selectedSkeleton->hasAnimation(m_selectedAnimation)) { Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); m_sliderMaximum = static_cast(anim->getLength() * 1000); + } else if (Ogre::MeshPtr mesh = m_selectedEntity->getMesh(); + mesh && mesh->hasAnimation(m_selectedAnimation)) { + // Mesh-level (VAT_POSE) clip — morph-weight animation or Alembic vertex + // cache. These have no skeleton; take the length off the mesh Animation + // so the timeline scrubs (skeleton-only derivation left it at 0). + m_sliderMaximum = static_cast( + mesh->getAnimation(m_selectedAnimation)->getLength() * 1000); } // Reset loop region to span the whole animation whenever a new clip is @@ -396,10 +404,20 @@ void AnimationControlController::setSliderValue(int ms) void AnimationControlController::setAnimationLength(double length) { - if (!m_selectedSkeleton || m_selectedAnimation.empty()) return; - if (!m_selectedSkeleton->hasAnimation(m_selectedAnimation)) return; + if (m_selectedAnimation.empty()) return; + + // Resolve the Animation from the skeleton (skeletal clip) OR the mesh + // (VAT_POSE morph-weight / vertex-cache clip). Length is settable for both. + Ogre::Animation* anim = nullptr; + if (m_selectedSkeleton && m_selectedSkeleton->hasAnimation(m_selectedAnimation)) { + anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); + } else if (m_selectedEntity) { + if (Ogre::MeshPtr mesh = m_selectedEntity->getMesh(); + mesh && mesh->hasAnimation(m_selectedAnimation)) + anim = mesh->getAnimation(m_selectedAnimation); + } + if (!anim) return; - Ogre::Animation* anim = m_selectedSkeleton->getAnimation(m_selectedAnimation); anim->setLength(static_cast(length)); m_sliderMaximum = static_cast(length * 1000); @@ -949,23 +967,55 @@ QVariantList AnimationControlController::allMorphRows() const if (poseName.empty()) continue; QVariantList keyTimes; - if (mesh->hasAnimation(poseName)) { - // The importer (MeshProcessor) groups same-named poses - // across submeshes into a single Animation with one - // VAT_POSE track per submesh. Pull only the track - // matching this pose's target handle — otherwise a - // shape that appears on body + head would show its - // diamonds twice in the dope sheet. - Ogre::Animation* anim = mesh->getAnimation(poseName); - const unsigned short handle = pose->getTarget(); - if (anim->hasVertexTrack(handle)) { - Ogre::VertexAnimationTrack* track = anim->getVertexTrack(handle); - if (track && track->getAnimationType() == Ogre::VAT_POSE) { - for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) - keyTimes.append(static_cast(track->getKeyFrame(i)->getTime())); + const unsigned short handle = pose->getTarget(); + + // Prefer the animated-weight track on the shared "MorphAnim" clip + // (Slice 2 #519 — multiple keyframes at varying influence). Fall back + // to the static per-target Animation (single shape keyframe at t=0) + // when the target has no weight animation yet. + auto appendTrackTimes = [&](const std::string& clipName) -> bool { + if (!mesh->hasAnimation(clipName)) return false; + Ogre::Animation* anim = mesh->getAnimation(clipName); + if (!anim->hasVertexTrack(handle)) return false; + Ogre::VertexAnimationTrack* track = anim->getVertexTrack(handle); + if (!track || track->getAnimationType() != Ogre::VAT_POSE) return false; + // A MorphAnim track carries keyframes for every target on this + // handle; keep only the ones that reference THIS pose so each row + // shows its own diamonds. + bool any = false; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + bool refsThisPose = false; + for (const auto& ref : kf->getPoseReferences()) { + const Ogre::Pose* rp = + (ref.poseIndex < mesh->getPoseList().size()) + ? mesh->getPoseList()[ref.poseIndex] : nullptr; + if (rp && rp->getName() == poseName) { refsThisPose = true; break; } + } + if (refsThisPose) { + keyTimes.append(static_cast(kf->getTime())); + any = true; } } + return any; + }; + + // Read the ACTIVE morph clip's keys (the one the user is editing), so + // switching clips in the dropdown shows that clip's diamonds. If the + // active clip isn't on this mesh (e.g. right after IMPORTING a model + // whose clip is "Sniff" while the app default is still "MorphAnim", and + // the auto-adopt signal hasn't landed yet), resolve the first real morph + // clip on the mesh directly — don't depend on active-clip timing. Fall + // back to the static shape-only clip when neither has a key here. + std::string clipToRead = + MorphAnimationManager::instance()->activeMorphClip().toStdString(); + if (!mesh->hasAnimation(clipToRead)) { + const QStringList clips = MorphAnimationManager::instance()->morphClips(); + if (!clips.isEmpty()) + clipToRead = clips.first().toStdString(); } + if (!appendTrackTimes(clipToRead)) + appendTrackTimes(poseName); // static shape-only clip QVariantMap row; row[QStringLiteral("name")] = QString::fromStdString(poseName); diff --git a/src/Assimp/AnimationProcessor.cpp b/src/Assimp/AnimationProcessor.cpp index 42dea8a98..f3f837af9 100644 --- a/src/Assimp/AnimationProcessor.cpp +++ b/src/Assimp/AnimationProcessor.cpp @@ -1,7 +1,178 @@ #include "AnimationProcessor.h" +#include +#include + AnimationProcessor::AnimationProcessor(Ogre::SkeletonPtr skeleton): skeleton(skeleton) {} +namespace { + +// Reconstruct the pose NAME MeshProcessor assigned to morph-target index `am` +// of aiMesh `mesh`. MeshProcessor uses the aiAnimMesh name when present, else +// the "Shape_" fallback — we must reproduce it exactly so name-based +// pose lookup lands on the right Ogre::Pose. Returns empty when out of range. +std::string morphTargetPoseName(const aiMesh* mesh, unsigned int am) +{ + if (!mesh || am >= mesh->mNumAnimMeshes) + return {}; + const aiAnimMesh* anim = mesh->mAnimMeshes[am]; + if (anim && anim->mName.length > 0) + return std::string(anim->mName.C_Str()); + return std::string("Shape_") + std::to_string(am); +} + +// Find the pose index (into mesh->getPoseList()) for the first pose named +// `name`. -1 if none — MeshProcessor SKIPS all-zero-delta targets (lazy +// createPose), so aiAnimMesh index != pose-list index; only a name lookup is +// reliable, and a skipped (zero-delta) target correctly has no pose. +int poseIndexForName(const Ogre::MeshPtr& mesh, const std::string& name) +{ + if (name.empty()) + return -1; + const auto& poses = mesh->getPoseList(); + for (unsigned short i = 0; i < poses.size(); ++i) + if (poses[i] && poses[i]->getName() == name) + return static_cast(i); + return -1; +} + +// Locate the aiMesh a morph channel targets. aiMeshMorphAnim::mName is the NODE +// name; its mValues[] index the aiAnimMeshes of the mesh attached to that node. +// glTF nodes carry a single mesh, which is the case Assimp populates morph +// channels for; when a node has several meshes only meshes exposing anim-meshes +// are candidates and we take the first (multi-morph-mesh nodes don't occur in +// glTF's one-mesh-per-node model). +const aiMesh* meshForMorphChannel(const aiScene* scene, const aiNode* node) +{ + if (!scene || !node) + return nullptr; + for (unsigned int i = 0; i < node->mNumMeshes; ++i) { + const unsigned int meshIdx = node->mMeshes[i]; + if (meshIdx >= scene->mNumMeshes) + continue; + const aiMesh* mesh = scene->mMeshes[meshIdx]; + if (mesh && mesh->mNumAnimMeshes > 0) + return mesh; + } + return nullptr; +} + +} // namespace + +void AnimationProcessor::processMorphWeightAnimations(const Ogre::MeshPtr& mesh, const aiScene* scene) +{ + if (!mesh || !scene) + return; + // No poses on the mesh → nothing a weight clip could reference. + if (mesh->getPoseCount() == 0) + return; + + unsigned int generatedNameCounter = 0; + + for (unsigned int a = 0; a < scene->mNumAnimations; ++a) { + const aiAnimation* anim = scene->mAnimations[a]; + if (!anim || anim->mNumMorphMeshChannels == 0) + continue; + + const double ticksPerSecond = (anim->mTicksPerSecond != 0.0) ? anim->mTicksPerSecond : 24.0; + + // One Ogre::Animation (the weight clip) per aiAnimation, named after it. + std::string clipName = anim->mName.C_Str(); + if (clipName.empty()) + clipName = std::string("MorphAnim_") + std::to_string(generatedNameCounter++); + // Avoid colliding with the per-target SHAPE clips MeshProcessor created + // (each named exactly a pose name) or an already-built clip. + if (mesh->hasAnimation(clipName)) { + std::string base = clipName; + unsigned int suffix = 1; + do { + clipName = base + "_" + std::to_string(suffix++); + } while (mesh->hasAnimation(clipName)); + } + + Ogre::Animation* clip = mesh->createAnimation(clipName, 0.0f); + float maxTime = 0.0f; + bool wroteAnyKey = false; + + for (unsigned int c = 0; c < anim->mNumMorphMeshChannels; ++c) { + const aiMeshMorphAnim* morph = anim->mMorphMeshChannels[c]; + if (!morph || morph->mNumKeys == 0) + continue; + + // Resolve the node → mesh this channel drives so we can map value + // indices to the matching morph-target pose names. + const aiNode* node = scene->mRootNode + ? scene->mRootNode->FindNode(morph->mName) + : nullptr; + const aiMesh* srcMesh = meshForMorphChannel(scene, node); + // FALLBACK node resolution: some exporters (our glTF weights inject + // after mesh-split) name the morph channel's node after a SPLIT + // submesh (e.g. "sniff_submesh0*0") that FindNode can't match to the + // scene node holding the mesh. When the direct node lookup fails, + // scan ALL meshes for the first one carrying anim-meshes — a + // single-morph-mesh model (the common case) resolves unambiguously. + if (!srcMesh) { + for (unsigned int mi = 0; mi < scene->mNumMeshes; ++mi) { + const aiMesh* cand = scene->mMeshes[mi]; + if (cand && cand->mNumAnimMeshes > 0) { srcMesh = cand; break; } + } + } + if (!srcMesh) + continue; + + for (unsigned int k = 0; k < morph->mNumKeys; ++k) { + const aiMeshMorphKey& key = morph->mKeys[k]; + const float t = static_cast(key.mTime / ticksPerSecond); + + for (unsigned int j = 0; j < key.mNumValuesAndWeights; ++j) { + const unsigned int targetIdx = key.mValues[j]; + const double weight = key.mWeights[j]; + + // Map anim-mesh target index -> pose name -> pose index. + // A skipped zero-delta target has no pose: -1, drop it. + const std::string poseName = morphTargetPoseName(srcMesh, targetIdx); + const int pi = poseIndexForName(mesh, poseName); + if (pi < 0) + continue; + + // Track keyed on the pose's target submesh handle — same + // grouping the authoring path uses (targets on one submesh + // share a track; keyframes at time t carry a pose ref each). + const unsigned short handle = mesh->getPoseList()[static_cast(pi)]->getTarget(); + Ogre::VertexAnimationTrack* track = clip->hasVertexTrack(handle) + ? clip->getVertexTrack(handle) + : clip->createVertexTrack(handle, Ogre::VAT_POSE); + if (!track) + continue; + + // Fetch-or-create the keyframe at this time on this track. + Ogre::VertexPoseKeyFrame* kf = nullptr; + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) { + auto* existing = static_cast(track->getKeyFrame(ki)); + if (std::abs(existing->getTime() - t) < 1e-4f) { kf = existing; break; } + } + if (!kf) + kf = track->createVertexPoseKeyFrame(t); + + kf->addPoseReference(static_cast(pi), + static_cast(weight)); + wroteAnyKey = true; + if (t > maxTime) + maxTime = t; + } + } + } + + if (!wroteAnyKey) { + // No usable channel resolved to a pose — drop the empty clip so we + // don't surface a zero-track animation in the list. + mesh->removeAnimation(clipName); + continue; + } + clip->setLength(maxTime); + } +} + void AnimationProcessor::processAnimations(const aiScene* scene) { for(auto i = 0u; i < scene->mNumAnimations; i++) { aiAnimation* animation = scene->mAnimations[i]; diff --git a/src/Assimp/AnimationProcessor.h b/src/Assimp/AnimationProcessor.h index 015b0a198..ff96a619b 100644 --- a/src/Assimp/AnimationProcessor.h +++ b/src/Assimp/AnimationProcessor.h @@ -9,6 +9,17 @@ class AnimationProcessor AnimationProcessor(Ogre::SkeletonPtr skeleton); void processAnimations(const aiScene* scene); + // Consume Assimp's morph-weight channels (aiAnimation::mMorphMeshChannels, + // populated by the glTF2 importer) and build mesh-level VAT_POSE weight + // clips on the already-built Ogre mesh — the SAME structure + // MorphAnimationManager authors, so the dope sheet / Animation list / + // playback treat imported and authored morph clips identically. + // + // MUST be called AFTER MeshProcessor::createMesh(): the morph poses it + // references only exist once the mesh + poses have been built. + // No-op when the scene has no morph channels or the mesh has no poses. + static void processMorphWeightAnimations(const Ogre::MeshPtr& mesh, const aiScene* scene); + private: void processAnimation(aiAnimation* animation, const aiScene* scene); void processAnimationChannel(aiNodeAnim* nodeAnim, Ogre::Animation* animation, const aiScene* scene, unsigned int channelIndex, Ogre::Real mTicksPerSecond); diff --git a/src/Assimp/Importer.cpp b/src/Assimp/Importer.cpp index d4ea8079a..84768f68f 100644 --- a/src/Assimp/Importer.cpp +++ b/src/Assimp/Importer.cpp @@ -182,15 +182,25 @@ Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool conv // Process materials materialProcessor.loadScene(scene); - // Process the skeleton whenever the scene has bones (skinned mesh) or animations. - // A mesh can be skinned without having any animations (e.g. a rigged bind-pose). + // Process the skeleton whenever the scene has bones (skinned mesh) or a + // SKELETAL animation (one with node channels). A mesh can be skinned without + // animations (rigged bind-pose). Crucially, a MORPH-only animation + // (aiAnimation with mMorphMeshChannels but zero mNumChannels) must NOT + // trigger skeleton creation: doing so builds an empty skeleton, and + // setBindingPose() → deriveRootBone() then asserts on the empty bone list, + // producing a mesh that fails to load/render. So require at least one node + // channel, not merely HasAnimations(). bool hasBones = false; for(unsigned i = 0; i < scene->mNumMeshes && !hasBones; ++i) hasBones = scene->mMeshes[i]->mNumBones > 0; + bool hasSkeletalAnim = false; + for(unsigned i = 0; i < scene->mNumAnimations && !hasSkeletalAnim; ++i) + hasSkeletalAnim = scene->mAnimations[i]->mNumChannels > 0; + const bool isZup = (m_sceneUpAxis == 2); - if(hasBones || scene->HasAnimations()) { + if(hasBones || hasSkeletalAnim) { skeleton = Ogre::SkeletonManager::getSingleton().create(modelName+".skeleton", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, true); BoneProcessor boneProcessor; // Create bones at their native FBX-space positions (no Z-up bake yet). @@ -228,5 +238,13 @@ Ogre::MeshPtr AssimpToOgreImporter::loadModel(const std::string& path, bool conv meshProcessor.processNode(scene->mRootNode, scene); Ogre::MeshPtr ogreMesh = meshProcessor.createMesh(modelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, materialProcessor); + // Consume authored morph-WEIGHT animation channels (aiAnimation:: + // mMorphMeshChannels) into mesh-level VAT_POSE weight clips. Must run AFTER + // createMesh — the morph poses these clips reference only exist once the + // mesh + poses have been built by MeshProcessor. No-op when the scene has + // no morph channels or the mesh has no poses. + if (ogreMesh && scene->HasAnimations()) + AnimationProcessor::processMorphWeightAnimations(ogreMesh, scene); + return ogreMesh; } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 8f4adb7bf..85ad3fa01 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -3,6 +3,7 @@ #include "GamificationManager.h" #include "Manager.h" #include "MeshImporterExporter.h" +#include "AlembicImporter.h" #include "SceneLightsIO.h" #include "SceneLightsCLI.h" #include "AnimationMerger.h" @@ -1339,6 +1340,19 @@ MeshInfo CLIPipeline::extractMeshInfo(const Ogre::Entity* entity, const QString& } } + // Mesh-level (VAT_POSE) animations — morph-weight clips + Alembic vertex + // caches. These live on the Ogre::Mesh, not the skeleton, so the skeletal + // loop above never sees them; report them too so round-trip checks (and + // `qtmesh info`) reflect morph/vertex animation. + for (unsigned short a = 0; a < mesh->getNumAnimations(); ++a) { + auto* anim = mesh->getAnimation(a); + if (!anim) continue; + info.animations.append({ + QString::fromStdString(anim->getName()), + anim->getLength() + }); + } + // Bounding box auto bb = mesh->getBounds(); info.bbMin = bb.getMinimum(); @@ -2126,6 +2140,7 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) // or: anim --decimate-step S [-o ] [--animation ] QString filePath, oldName, newName, outputPath, animationFilter; bool listMode = false; + bool infoMode = false; // #519: `anim .abc --info` — vertex-cache metadata bool analyzeMode = false; bool renameMode = false; bool mergeMode = false; @@ -2162,6 +2177,7 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) QString arg(argv[i]); if (arg == "anim" || arg == "--cli") continue; if (arg == "--list") { listMode = true; continue; } + if (arg == "--info") { infoMode = true; continue; } if (arg == "--analyze") { analyzeMode = true; continue; } if (arg == "--json") { jsonOutput = true; continue; } if (arg == "--rename" && i + 2 < argc) { @@ -2266,6 +2282,47 @@ int CLIPipeline::cmdAnim(int argc, char* argv[]) filePath = positional[0]; + // #519: `anim .abc --info [--json]` — vertex-cache metadata (frames, + // verts, fps, duration, storage) read from the Alembic header without + // decoding all frames. Only meaningful for .abc; other formats fall through + // to the normal anim modes. + if (infoMode && QFileInfo(filePath).suffix().compare("abc", Qt::CaseInsensitive) == 0) { + SentryReporter::addBreadcrumb("cli.anim", + QString("Anim info .abc %1").arg(QFileInfo(filePath).fileName())); + if (!AlembicImporter::available()) { + err() << "Error: Alembic support not compiled in (rebuild with " + "-DENABLE_ALEMBIC=ON)." << Qt::endl; + return 1; + } + const AlembicImporter::InfoResult info = AlembicImporter::readInfo(filePath); + if (!info.ok) { + err() << "Error: " << info.error << Qt::endl; + return 1; + } + if (jsonOutput) { + QJsonObject o; + o["file"] = QFileInfo(filePath).fileName(); + o["mesh"] = info.meshName; + o["frames"] = info.frameCount; + o["vertices"] = info.vertexCount; + o["triangles"] = info.faceCount; + o["fps"] = info.fps; + o["durationSec"] = info.durationSec; + o["storage"] = info.storage; + cliWrite(QString::fromUtf8(QJsonDocument(o).toJson(QJsonDocument::Compact)) + "\n"); + } else { + cliWrite(QString("Alembic vertex cache: %1\n").arg(QFileInfo(filePath).fileName())); + cliWrite(QString(" mesh: %1\n").arg(info.meshName)); + cliWrite(QString(" frames: %1\n").arg(info.frameCount)); + cliWrite(QString(" vertices: %1\n").arg(info.vertexCount)); + cliWrite(QString(" triangles: %1\n").arg(info.faceCount)); + cliWrite(QString(" fps: %1\n").arg(info.fps)); + cliWrite(QString(" duration: %1s\n").arg(info.durationSec, 0, 'f', 3)); + cliWrite(QString(" storage: %1\n").arg(info.storage)); + } + return 0; + } + // #411: text-to-motion (template-clip MVP). Self-contained — load → match a // motion-library clip → retarget → export. Handled before the other modes. if (generateMode) { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1a6c736dd..87ce37a95 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -152,6 +152,8 @@ HDR/HdrBundledLibrary.cpp MorphAnimationManager.cpp NodeAnimationManager.cpp PoseLibrary.cpp +VertexAnimationManager.cpp +AlembicImporter.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp @@ -710,6 +712,10 @@ endif() # Link ONNX Runtime if enabled (#404 — AI PBR map synthesis). The imported # target is a SHARED lib, so copy it next to the binary at build time or it # won't be found at runtime. +if(ENABLE_ALEMBIC) + target_link_libraries(${CMAKE_PROJECT_NAME} qtmesh_alembic) +endif() + if(ENABLE_ONNX) target_link_libraries(${CMAKE_PROJECT_NAME} qtmesh_onnx) if(QTMESH_ONNX_LIB_DIR) @@ -849,6 +855,10 @@ if(BUILD_TESTS) target_link_libraries(UnitTests stable-diffusion) endif() + if(ENABLE_ALEMBIC) + target_link_libraries(UnitTests qtmesh_alembic) + endif() + # Link ONNX Runtime for tests if enabled (#404) if(ENABLE_ONNX) target_link_libraries(UnitTests qtmesh_onnx) diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index 4d1e1ae04..d0579793f 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -594,6 +594,12 @@ void EditModeController::exitEditMode(bool commitChanges) // would be surprising mid-cut. if (m_knifeSession.active) cancelKnife(); + // A morph sculpt session is non-destructive: restore the base BEFORE the + // normal commit so exiting Edit Mode never bakes the sculpt into the base. + // endMorphSculpt() restores the snapshot into the editable mesh; the commit + // below then writes those (pristine) positions back. + if (m_morphSculptActive) endMorphSculpt(); + if (commitChanges && m_editableMesh && m_editEntity) { bool ok = m_editableMesh->commitToEntity(m_editEntity); refreshNormalVisualizer(); @@ -654,6 +660,69 @@ void EditModeController::notifyMeshDataChanged() emit meshDataChanged(); } +bool EditModeController::beginMorphSculpt() +{ + // Requires an active Edit Mode session on a real mesh. + if (!m_editModeActive || !m_editableMesh || !m_editEntity) + return false; + if (m_morphSculptActive) + return true; // already sculpting + + // Snapshot the CURRENT vertex positions per submesh. These are the base + // shape the morph deltas are measured against and what we restore on exit, + // so morph authoring never permanently changes the base mesh (Blender + // shape-key / Maya blend-shape behaviour). + m_morphBaseSnapshot.clear(); + const auto& subs = m_editableMesh->subMeshes(); + m_morphBaseSnapshot.reserve(subs.size()); + for (const auto& sub : subs) { + std::vector snap; + snap.reserve(sub.vertices.size()); + for (const auto& v : sub.vertices) + snap.push_back(v.position); + m_morphBaseSnapshot.push_back(std::move(snap)); + } + + m_morphSculptActive = true; + SentryReporter::addBreadcrumb("scene.anim.morph", "begin morph sculpt"); + emit morphSculptChanged(); + return true; +} + +void EditModeController::endMorphSculpt() +{ + if (!m_morphSculptActive) + return; + m_morphSculptActive = false; + + // Restore the pristine base positions captured at begin, then push them to + // the GPU so the viewport shows the unaltered base. Any captured target + // already lives on the mesh as a Pose + weight, independent of the base. + if (m_editableMesh && m_editEntity && + m_morphBaseSnapshot.size() == m_editableMesh->subMeshes().size()) + { + for (size_t s = 0; s < m_morphBaseSnapshot.size(); ++s) { + const auto& snap = m_morphBaseSnapshot[s]; + const size_t n = std::min(snap.size(), + m_editableMesh->subMeshes()[s].vertices.size()); + for (size_t vi = 0; vi < n; ++vi) + m_editableMesh->setVertexPosition(s, vi, snap[vi]); + } + if (m_normalsMode == 0) + m_editableMesh->recalculateNormals(); + else + m_editableMesh->recalculateNormalsFlat(); + m_editableMesh->commitToEntity(m_editEntity); + refreshNormalVisualizer(); + updateSelectionOverlay(); + } + + m_morphBaseSnapshot.clear(); + SentryReporter::addBreadcrumb("scene.anim.morph", "end morph sculpt (base restored)"); + emit morphSculptChanged(); + emit meshDataChanged(); +} + void EditModeController::onSelectionChanged() { // If selection changes while in edit mode and the edited entity is no diff --git a/src/EditModeController.h b/src/EditModeController.h index 257365566..a26e29987 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -170,6 +170,22 @@ class EditModeController : public QObject Q_INVOKABLE void notifyMeshDataChanged(); /// @} + /// @name Morph sculpt session (Blender-style non-destructive authoring, #519) + /// @{ + /// True while a base-preserving morph sculpt session is active. While on, + /// vertex edits are treated as sculpt work for a morph target: `+ Add` + /// captures the delta vs the base, and ending the session (or exiting Edit + /// Mode) RESTORES the base mesh — the base is never permanently changed, + /// matching Blender shape keys / Maya blend shapes. + Q_PROPERTY(bool morphSculptActive READ morphSculptActive NOTIFY morphSculptChanged) + bool morphSculptActive() const { return m_morphSculptActive; } + /// Begin a morph sculpt session (must already be in Edit Mode). + Q_INVOKABLE bool beginMorphSculpt(); + /// End the session. Always restores the base mesh from the entry snapshot + /// (the captured target already lives on the mesh as a Pose + weight). + Q_INVOKABLE void endMorphSculpt(); + /// @} + /// @name Component selection mode (Vertex/Edge/Face) /// @{ int selectionMode() const { return static_cast(m_selectionMode); } @@ -871,6 +887,8 @@ class EditModeController : public QObject void segmentFinished(const QString& status, bool isError); /// Emitted when entering or exiting edit mode. void editModeChanged(); + /// Emitted when a morph sculpt session starts/ends (#519). + void morphSculptChanged(); /// Emitted when the mesh data is modified during edit mode. void meshDataChanged(); /// Emitted when the selection changes (to update canEnterEditMode). @@ -931,6 +949,13 @@ private slots: std::unique_ptr m_editableMesh; Ogre::Entity* m_editEntity = nullptr; + // Morph sculpt session (#519): pristine base positions captured at + // beginMorphSculpt(), restored on endMorphSculpt() so the base mesh is + // never permanently altered by morph-target authoring. One entry per + // submesh, flat xyz — matches EditableSubMesh vertex ordering. + bool m_morphSculptActive = false; + std::vector> m_morphBaseSnapshot; + void refreshNormalVisualizer(); // Component selection state diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index cb3d8d506..2fff1dfbd 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -34,6 +34,8 @@ THE SOFTWARE. #include "UvSeamData.h" #include #include +#include +#include #include #include #include @@ -610,6 +612,14 @@ bool EditableMesh::loadFromAssimpFile(const std::string& path, // n-gon submeshes downstream. if (!sawNGon) sub.faces.clear(); + // Snapshot the bind positions so morph-target authoring can diff the + // edited vertices against the pre-edit baseline. (loadFromEntity does + // this too; the n-gon path must match or morph capture sees orig=0 and + // reports "nothing moved" — the OBJ morph bug.) + sub.originalPositions.reserve(sub.vertices.size()); + for (const auto& v : sub.vertices) + sub.originalPositions.push_back(v.position); + m_subMeshes.push_back(std::move(sub)); } @@ -796,6 +806,19 @@ bool EditableMesh::commitToEntity(Ogre::Entity* entity) } } + // For entities with vertex (pose / morph) animation, Ogre renders from the + // per-frame pose vertex buffer — NOT the mesh VBO we just wrote — and only + // recomputes it when the animation state is dirty. During morph authoring + // the frame loop is usually paused (isPlaying == false), so without this + // the edited vertices never reach the screen. Bump the state set's dirty + // frame and re-run the animation so the pose buffer is re-derived from the + // updated base positions immediately. + if (entity->hasVertexAnimation()) { + if (auto* states = entity->getAllAnimationStates()) + states->_notifyDirty(); + entity->_updateAnimation(); + } + // Clear the cached source-file path: the live GPU buffers have // diverged from the imported asset. Subsequent enterEditMode calls // must use the legacy loadFromEntity path so user edits aren't diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index 2b89dfcb1..5048bef02 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -47,6 +47,7 @@ THE SOFTWARE. #include #include #include +#include #include #include #include @@ -881,6 +882,28 @@ class FBXDocumentBuilder } } + // Morph weight-animation A5 — count AnimationStack / Layer / + // CurveNode / Curve for morph WEIGHT clips. These are ADDED to + // (not merged with) the skeletal anim counts above, mirroring + // how writeMorphAnimations() emits a separate stack+layer per + // morph clip. Per morph clip: + // +1 AnimationStack, +1 AnimationLayer + // +N AnimationCurveNode ("Number"/DeformPercent), +N AnimationCurve + // where N = distinct animated channels (poses) in that clip. + // Must EXACTLY match writeMorphAnimations() or the file is + // malformed. Independent of skeleton presence. + { + std::vector morphClips = collectMorphWeightClips(); + for (Ogre::Animation* clip : morphClips) { + int animatedChannels = countMorphAnimatedChannels(clip); + if (animatedChannels == 0) continue; // nothing to write → no stack + animStackCount += 1; + animLayerCount += 1; + animCurveNodeCount += animatedChannels; + animCurveCount += animatedChannels; + } + } + int totalObjects = 1 + modelCount + geomCount + matCount + nodeAttrCount + deformerCount + poseCount + textureCount + videoCount + animStackCount + animLayerCount + @@ -968,6 +991,13 @@ class FBXDocumentBuilder if (m_skeleton->getNumAnimations() > 0) writeAnimations(); } + // Morph weight-animation A5 — DeformPercent animation for the + // BlendShapeChannels written above. MUST run after + // writeBlendShapeDeformers() (needs m_poseChannelIds populated) + // and is independent of the skeleton — a pure-morph mesh with + // no skeleton still animates its weights. + if (!m_skeletonOnly) + writeMorphAnimations(); m_w.endNode(); // Objects } @@ -1600,6 +1630,99 @@ class FBXDocumentBuilder } } + // ── Morph weight clip enumeration (A5) ─────────────────────── + // A morph WEIGHT clip is a MESH-level Ogre::Animation that is NOT + // a per-target shape clip (those are named exactly a pose name). + // Mirrors MorphAnimationManager::morphClips() / isPoseShapeClip(). + static bool isPoseShapeClipName(const Ogre::Mesh* mesh, const std::string& name) + { + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == name) return true; + return false; + } + + std::vector collectMorphWeightClips() const + { + std::vector clips; + if (!m_mesh) return clips; + // No poses → no channels a weight clip could animate. + if (m_mesh->getPoseList().empty()) return clips; + for (unsigned short i = 0; i < m_mesh->getNumAnimations(); ++i) + { + Ogre::Animation* a = m_mesh->getAnimation(i); + if (!a) continue; + if (isPoseShapeClipName(m_mesh, a->getName())) continue; + // Must carry at least one VAT_POSE track with keyframes, + // otherwise there is nothing to animate. + bool hasWeightKeys = false; + for (const auto& [handle, track] : a->_getVertexTrackList()) + { + (void)handle; + if (track && track->getAnimationType() == Ogre::VAT_POSE + && track->getNumKeyFrames() > 0) { hasWeightKeys = true; break; } + } + if (hasWeightKeys) clips.push_back(a); + } + return clips; + } + + // Does pose `p` get an exported BlendShapeChannel? True iff its + // target handle matches a submesh whose geometry is emitted — the + // exact condition writeBlendShapeDeformers() uses to create the + // channel. Count-safe: does NOT depend on m_poseChannelIds (which + // is only populated during writeObjects, after the count pass). + bool poseHasExportedChannel(const Ogre::Pose* p) const + { + if (!p || !m_mesh) return false; + const unsigned short target = p->getTarget(); + for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) + { + const Ogre::SubMesh* subMesh = m_mesh->getSubMesh(si); + const Ogre::VertexData* vData = subMesh->useSharedVertices + ? m_mesh->sharedVertexData : subMesh->vertexData; + if (!vData || vData->vertexCount == 0) continue; // no geometry emitted + const unsigned short handle = subMesh->useSharedVertices + ? 0 : static_cast(si + 1); + if (handle == target) return true; + } + return false; + } + + // Distinct poses referenced by any keyframe of any VAT_POSE track + // in `clip` that also have an exported BlendShapeChannel. Each such + // pose yields exactly one "Number" AnimationCurveNode + one + // AnimationCurve when the clip is written. + std::vector animatedChannelPoses(Ogre::Animation* clip) const + { + std::vector ordered; // preserve deterministic order + std::set seen; + const Ogre::PoseList& poseList = m_mesh->getPoseList(); + for (const auto& [handle, track] : clip->_getVertexTrackList()) + { + (void)handle; + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) + { + auto* kf = static_cast(track->getKeyFrame(ki)); + for (const auto& ref : kf->getPoseReferences()) + { + if (ref.poseIndex >= poseList.size()) continue; + const Ogre::Pose* p = poseList[ref.poseIndex]; + if (!p || seen.count(p)) continue; + if (!poseHasExportedChannel(p)) continue; + seen.insert(p); + ordered.push_back(p); + } + } + } + return ordered; + } + + int countMorphAnimatedChannels(Ogre::Animation* clip) const + { + return static_cast(animatedChannelPoses(clip).size()); + } + // ── Morph A4b: BlendShape deformers (morph targets) ────────── // // FBX represents blend shapes as a chain of nodes hanging off @@ -1655,6 +1778,14 @@ class FBXDocumentBuilder int64_t channelId = nextId(); int64_t shapeId = nextId(); m_channelConns.push_back({channelId, blendShapeId, shapeId, p->getName()}); + // Morph weight-animation A5 — remember which FBX + // BlendShapeChannel corresponds to this Ogre::Pose so + // writeMorphAnimations() can attach a DeformPercent + // AnimationCurveNode to it. Keyed by the pose POINTER + // (pose names aren't guaranteed unique across submeshes, + // but the pointer is; morph clips reference poses by + // index into m_mesh->getPoseList()). + m_poseChannelIds[p] = channelId; // BlendShapeChannel — the user-facing weight target. m_w.beginNode("Deformer"); @@ -1973,6 +2104,224 @@ class FBXDocumentBuilder m_w.endNode(); // AnimationCurveNode } + // ── Morph weight animation (A5) ────────────────────────────── + // + // Animates each BlendShapeChannel's DeformPercent (0..100) over + // time so authored morph weight clips round-trip through FBX. The + // object/connection graph MIRRORS the skeletal path exactly, but + // targets BlendShapeChannels instead of bone Models: + // + // AnimationStack (one per morph clip) + // ↑OO AnimationLayer + // ↑OO AnimationCurveNode ("Number", d|DeformPercent) + // — OP→ BlendShapeChannel (property "DeformPercent") + // ↑OO AnimationCurve (OP→ node, channel "d|DeformPercent") + // + // Assimp's FBXConverter::ProcessMorphAnimDatas reads the + // "d|DeformPercent" curve on any AnimationCurveNode whose Target is + // a BlendShapeChannel and emits aiMeshMorphAnim keys with + // value=channelIndex, weight=value/100 — which our import side + // (AnimationProcessor::processMorphWeightAnimations) rebuilds into + // VAT_POSE weight keyframes. + void writeMorphAnimations() + { + if (!m_mesh) return; + const Ogre::PoseList& poseList = m_mesh->getPoseList(); + + std::vector morphClips = collectMorphWeightClips(); + for (Ogre::Animation* clip : morphClips) + { + // Distinct animated poses (== FBX channels) for this clip. + std::vector channelPoses = animatedChannelPoses(clip); + if (channelPoses.empty()) continue; // matches the count pre-pass skip + + int64_t stackId = nextId(); + int64_t layerId = nextId(); + m_morphAnimStackIds.push_back(stackId); + m_morphAnimLayerToStack.push_back({layerId, stackId}); + + const double duration = clip->getLength(); + const int64_t startTime = 0; + const int64_t stopTime = static_cast(duration * FBX_TICKS_PER_SECOND); + + // AnimationStack + m_w.beginNode("AnimationStack"); + m_w.writePropertyL(stackId); + m_w.writePropertyS(clip->getName() + std::string("\x00\x01", 2) + "AnimStack"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70KTime("LocalStart", startTime); + writeP70KTime("LocalStop", stopTime); + m_w.endNode(); // Properties70 + + m_w.endNode(); // AnimationStack + + // AnimationLayer + m_w.beginNode("AnimationLayer"); + m_w.writePropertyL(layerId); + m_w.writePropertyS(clip->getName() + "_Layer" + std::string("\x00\x01", 2) + "AnimLayer"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + writeP70Number("Weight", 100.0); + m_w.endNode(); // Properties70 + + m_w.endNode(); // AnimationLayer + + // One DeformPercent curve node + curve per animated channel. + for (const Ogre::Pose* p : channelPoses) + { + auto chIt = m_poseChannelIds.find(p); + if (chIt == m_poseChannelIds.end()) continue; // no exported channel + const int64_t channelId = chIt->second; + const unsigned short targetHandle = p->getTarget(); + + // Collect this pose's weight over time from whichever + // VAT_POSE track drives its target submesh. A pose is + // referenced at a keyframe's time with an influence in + // 0..1; FBX DeformPercent is 0..100. + std::vector keyTimes; + std::vector keyWeights; // 0..100 + const Ogre::VertexAnimationTrack* track = + clip->hasVertexTrack(targetHandle) + ? clip->getVertexTrack(targetHandle) + : nullptr; + if (track && track->getAnimationType() == Ogre::VAT_POSE) + { + // Find this pose's index in the mesh pose list. + unsigned short poseIdx = 0; + bool haveIdx = false; + for (unsigned short i = 0; i < poseList.size(); ++i) + if (poseList[i] == p) { poseIdx = i; haveIdx = true; break; } + + if (haveIdx) + { + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) + { + auto* kf = static_cast( + track->getKeyFrame(ki)); + float influence = 0.0f; + bool referenced = false; + for (const auto& ref : kf->getPoseReferences()) + { + if (ref.poseIndex == poseIdx) + { + influence = ref.influence; + referenced = true; + break; + } + } + // A keyframe on this track that does not list + // our pose means the pose is at rest (0) there. + (void)referenced; + keyTimes.push_back(static_cast( + kf->getTime() * FBX_TICKS_PER_SECOND)); + keyWeights.push_back(static_cast(influence) * 100.0); + } + } + } + + if (keyTimes.empty()) + { + // Degenerate safety: a channel we counted must emit a + // curve node + curve or the object counts break. Emit + // a single 0-weight key at t=0. + keyTimes.push_back(0); + keyWeights.push_back(0.0); + } + + // AnimationCurveNode — single "Number" channel + // (DeformPercent), default = first weight. + int64_t curveNodeId = nextId(); + writeDeformPercentCurveNode(curveNodeId, + keyWeights.empty() ? 0.0 : keyWeights[0]); + m_morphCurveNodeConns.push_back({curveNodeId, layerId, channelId}); + + // AnimationCurve — the DeformPercent key data. + int64_t curveId = nextId(); + writeAnimationCurveRecord(curveId, keyTimes, keyWeights); + m_morphCurveConns.push_back({curveId, curveNodeId}); + } + } + } + + // A single-scalar "Number" AnimationCurveNode carrying the + // DeformPercent property (mirrors writeAnimCurveNode but with one + // channel named "DeformPercent" instead of d|X/Y/Z, matching the + // property Assimp's prop_whitelist looks for on a channel node). + void writeDeformPercentCurveNode(int64_t id, double defaultValue) + { + m_w.beginNode("AnimationCurveNode"); + m_w.writePropertyL(id); + m_w.writePropertyS(std::string("DeformPercent") + std::string("\x00\x01", 2) + + "AnimCurveNode"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Properties70"); + m_w.endProperties(); + + m_w.beginNode("P"); + m_w.writePropertyS("d|DeformPercent"); m_w.writePropertyS("Number"); + m_w.writePropertyS(""); m_w.writePropertyS("A"); m_w.writePropertyD(defaultValue); + m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.endNode(); // Properties70 + m_w.endNode(); // AnimationCurveNode + } + + // Emit an AnimationCurve record from raw (time,value) arrays. Same + // record shape the skeletal writeCurve lambda uses, factored out so + // the morph path reuses the exact key-attr wiring. + void writeAnimationCurveRecord(int64_t curveId, + const std::vector& times, + const std::vector& vals) + { + m_w.beginNode("AnimationCurve"); + m_w.writePropertyL(curveId); + m_w.writePropertyS(std::string("\x00\x01", 2) + "AnimCurve"); + m_w.writePropertyS(""); + m_w.endProperties(); + + m_w.beginNode("Default"); m_w.writePropertyD(vals.empty() ? 0.0 : vals[0]); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("KeyVer"); m_w.writePropertyI(4008); m_w.endProperties(); m_w.endNodeLeaf(); + + m_w.beginNode("KeyTime"); + m_w.writePropertyArrayL(times); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyValueFloat"); + std::vector fVals(vals.begin(), vals.end()); + m_w.writePropertyArrayF(fVals); + m_w.endProperties(); + m_w.endNodeLeaf(); + + // AttrFlags — interpolation: cubic (same as skeletal curves). + m_w.beginNode("KeyAttrFlags"); + m_w.writePropertyArrayI({24840}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyAttrDataFloat"); + m_w.writePropertyArrayF({0.0f, 0.0f, 0.0218f, 0.0f}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.beginNode("KeyAttrRefCount"); + m_w.writePropertyArrayI({static_cast(times.size())}); + m_w.endProperties(); + m_w.endNodeLeaf(); + + m_w.endNode(); // AnimationCurve + } + // ── BindPose ───────────────────────────────────────────────── void writeBindPose() { @@ -2279,6 +2628,25 @@ class FBXDocumentBuilder writeConnection("OO", ch.channelId, ch.blendShapeId); writeConnection("OO", ch.shapeGeomId, ch.channelId); } + + // Morph weight-animation A5 — DeformPercent curve graph. + // Independent of the skeleton: a pure-morph mesh animates + // its BlendShapeChannel weights with no bones involved. + // AnimationStack → scene root (id 0) + // AnimationLayer → AnimationStack + // AnimationCurveNode → AnimationLayer (OO) + // AnimationCurveNode → BlendShapeChannel (OP "DeformPercent") + // AnimationCurve → AnimationCurveNode (OP "d|DeformPercent") + for (auto stackId : m_morphAnimStackIds) + writeConnection("OO", stackId, 0); + for (const auto& [layerId, stackId] : m_morphAnimLayerToStack) + writeConnection("OO", layerId, stackId); + for (const auto& acn : m_morphCurveNodeConns) { + writeConnection("OO", acn.curveNodeId, acn.layerId); + writeConnection("OP", acn.curveNodeId, acn.channelId, "DeformPercent"); + } + for (const auto& ac : m_morphCurveConns) + writeConnection("OP", ac.curveId, ac.curveNodeId, "d|DeformPercent"); } if (m_hasSkeleton) @@ -2552,6 +2920,37 @@ class FBXDocumentBuilder std::string channel; }; std::vector m_animCurveConns; + + // ── Morph weight animation (A5) ────────────────────────────── + // Maps each exported Ogre::Pose to the FBX BlendShapeChannel ID + // writeBlendShapeDeformers() assigned it. writeMorphAnimations() + // uses it to wire a "Number" AnimationCurveNode (property + // "DeformPercent") to the channel that pose animates. + std::map m_poseChannelIds; + + // AnimationStack/Layer for each morph WEIGHT clip (separate from + // the skeletal m_animStackIds so the skeletal path is untouched). + std::vector m_morphAnimStackIds; + std::vector> m_morphAnimLayerToStack; // layer→stack + + // DeformPercent AnimationCurveNode → BlendShapeChannel (OP) and + // → AnimationLayer (OO). Distinct from the skeletal T/R/S nodes: + // channelId is a BlendShapeChannel, property is always + // "DeformPercent". + struct MorphCurveNodeConn { + int64_t curveNodeId; + int64_t layerId; + int64_t channelId; // the BlendShapeChannel this drives + }; + std::vector m_morphCurveNodeConns; + + // AnimationCurve → DeformPercent AnimationCurveNode (OP, + // channel "d|DeformPercent"). + struct MorphCurveConn { + int64_t curveId; + int64_t curveNodeId; + }; + std::vector m_morphCurveConns; }; // ═══════════════════════════════════════════════════════════════════ diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index ab10b0622..234faa333 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -10,6 +10,7 @@ #include "NormalMapGenerator.h" #include "VATBaker.h" #include "MorphAnimationManager.h" +#include "AlembicImporter.h" #include "NodeAnimationManager.h" #include "PoseLibrary.h" #include "PrimitiveObject.h" @@ -680,6 +681,8 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("bake_vat"), &MCPServer::toolBakeVat}, {QStringLiteral("list_morph_targets"), &MCPServer::toolListMorphTargets}, {QStringLiteral("set_morph_weight"), &MCPServer::toolSetMorphWeight}, + {QStringLiteral("import_alembic"), &MCPServer::toolImportAlembic}, + {QStringLiteral("play_vertex_animation"), &MCPServer::toolPlayVertexAnimation}, {QStringLiteral("list_node_animations"), &MCPServer::toolListNodeAnimations}, {QStringLiteral("add_node_animation_clip"), &MCPServer::toolAddNodeAnimationClip}, {QStringLiteral("set_node_keyframe"), &MCPServer::toolSetNodeKeyframe}, @@ -723,6 +726,7 @@ bool MCPServer::isHeavyTool(const QString &name) QStringLiteral("open_scene"), QStringLiteral("bake_vat"), QStringLiteral("list_morph_targets"), + QStringLiteral("import_alembic"), QStringLiteral("cloud_upload") }; return heavyTools.contains(name); @@ -6349,6 +6353,72 @@ QJsonObject MCPServer::toolSetMorphWeight(const QJsonObject &args) QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); } +// --------------------------------------------------------------------------- +// Vertex-anim B3 (#519) — Alembic import + vertex-clip playback. +// --------------------------------------------------------------------------- + +QJsonObject MCPServer::toolImportAlembic(const QJsonObject &args) +{ + SentryReporter::addBreadcrumb("ai.tool_call", "import_alembic"); + + const QString filePath = args.value("file").toString(); + if (filePath.isEmpty()) + return makeErrorResult("Error: missing required 'file' argument"); + if (!QFileInfo::exists(filePath)) + return makeErrorResult(QString("Error: file not found: %1").arg(filePath)); + if (!AlembicImporter::available()) + return makeErrorResult( + "Error: this build has no Alembic support. Rebuild with -DENABLE_ALEMBIC=ON."); + + SentryReporter::addBreadcrumb("file.import", + QString("Importing Alembic cache %1").arg(filePath)); + + // importToScene creates scene nodes / entities, which can throw + // Ogre::Exception — run through runOgreOp so a failure returns a clean MCP + // error instead of taking down the server (matches the other Ogre tools). + return runOgreOp([&]() -> QJsonObject { + QString err; + Ogre::SceneNode* node = AlembicImporter::importToScene(filePath, &err); + if (!node) + return makeErrorResult( + err.isEmpty() ? QString("Error: failed to import %1").arg(filePath) : err); + + // Report the node + the vertex clips the import produced so the agent + // can immediately drive play_vertex_animation. + QJsonObject content; + content["ok"] = true; + content["file"] = filePath; + content["node"] = QString::fromStdString(node->getName()); + + QStringList entities, clips; + for (unsigned short i = 0; i < node->numAttachedObjects(); ++i) { + Ogre::MovableObject* obj = node->getAttachedObject(i); + if (!obj || obj->getMovableType() != "Entity") continue; + auto* ent = static_cast(obj); + entities.append(QString::fromStdString(ent->getName())); + if (auto* m = VertexAnimationManager::instance()) { + for (const QString& c : m->vertexClipsFor(ent)) + if (!clips.contains(c)) clips.append(c); + } + } + QJsonArray entArr, clipArr; + for (const QString& e : entities) entArr.append(e); + for (const QString& c : clips) clipArr.append(c); + content["entities"] = entArr; + content["vertexClips"] = clipArr; + return makeSuccessResult( + QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented))); + }); +} + +QJsonObject MCPServer::toolPlayVertexAnimation(const QJsonObject &args) +{ + SentryReporter::addBreadcrumb("ai.tool_call", "play_vertex_animation"); + // A vertex clip surfaces as an ordinary AnimationState, so playback is + // identical to play_animation — delegate to keep one code path. + return toolPlayAnimation(args); +} + // --------------------------------------------------------------------------- // Node-anim C6 — clip + keyframe authoring on the live scene. // --------------------------------------------------------------------------- @@ -8787,6 +8857,44 @@ QJsonArray MCPServer::buildToolsList() ); } + // import_alembic + { + QJsonObject props; + props["file"] = QJsonObject{{"type", "string"}, {"description", "Path to an Alembic (.abc) vertex cache."}}; + QJsonArray required; + required.append("file"); + appendTool( + "import_alembic", + "Import an Alembic (.abc) vertex cache into the live scene. Decodes the first " + "animated polymesh into a fixed-topology frame set and builds a VAT_POSE-animated " + "Ogre entity (cloth/sim/fluid bakes from Houdini/Blender). Returns the created node, " + "entities, and vertex-animation clip names — drive them with play_vertex_animation. " + "Requires a build with ENABLE_ALEMBIC=ON.", + props, + required + ); + } + + // play_vertex_animation + { + QJsonObject props; + props["entity"] = QJsonObject{{"type", "string"}, {"description", "Entity name (from import_alembic)."}}; + props["animation"] = QJsonObject{{"type", "string"}, {"description", "Vertex-animation clip name."}}; + props["play"] = QJsonObject{{"type", "boolean"}, {"description", "true to play (default), false to stop."}}; + props["loop"] = QJsonObject{{"type", "boolean"}, {"description", "Loop the clip (default true)."}}; + QJsonArray required; + required.append("entity"); + required.append("animation"); + appendTool( + "play_vertex_animation", + "Play / stop a vertex-animation clip on a live entity. The clip surfaces as an " + "ordinary Ogre::AnimationState, so this behaves like play_animation for skeletal " + "clips. Returns an error if the entity or clip is not found.", + props, + required + ); + } + // list_node_animations { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index da3d9c65e..8d7f1f718 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -276,6 +276,15 @@ private slots: /// Light — pure state poke on a live entity. QJsonObject toolSetMorphWeight(const QJsonObject &args); + /// Vertex-anim B3 (#519): import an Alembic (.abc) vertex cache into the + /// live scene as a VAT_POSE-animated mesh. Args: `file` (path). Heavy — + /// decodes the cache + builds an entity. Needs a build with ENABLE_ALEMBIC. + QJsonObject toolImportAlembic(const QJsonObject &args); + + /// Vertex-anim B3 (#519): enable + play a vertex-animation clip on an + /// entity. Args: `entity`, `animation`. Light — state poke on a live entity. + QJsonObject toolPlayVertexAnimation(const QJsonObject &args); + /// Node-anim C6: list node-animation clips on the live scene. /// No args. Light — pure read. QJsonObject toolListNodeAnimations(const QJsonObject &args); diff --git a/src/Manager.cpp b/src/Manager.cpp index 47c953ebc..7c512be64 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -88,7 +88,7 @@ Manager* Manager:: m_pSingleton = nullptr; QString Manager::mValidFileExtention = ".mesh .dae .blend .3ds .ase .obj .ifc .xgl .zgl .ply .dxf .lwo "\ ".lws .lxo .stl .x .ac .ms3d .cob .scn .bvh .csm .xml .irrmesh .irr .mdl .md2 .md3 "\ - ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd .rsd"; + ".pk3 .mdc .md5 .txt .smd .vta .m3 .3d .b3d .q3d .q3s .nff .nff .off .raw .ter .mdl .hmp .ndo .fbx .glb .gltf .vrm .tmd .rsd .abc"; //////////////////////////////////////// /// Static Member to build & destroy diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 386641df0..431a6ac70 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -27,6 +27,7 @@ THE SOFTWARE. */ #include "MeshImporterExporter.h" +#include "AlembicImporter.h" #include "FeedbackReportHelper.h" #include #include @@ -43,6 +44,10 @@ THE SOFTWARE. #include #include #include +#include +#include +#include +#include #include #include #include @@ -61,6 +66,7 @@ THE SOFTWARE. #include "OgreXML/pugixml.hpp" #include "AnimationMerger.h" +#include "MorphAnimationManager.h" #include "LightManager.h" #include "Manager.h" #include "SceneLightsIO.h" @@ -90,6 +96,10 @@ THE SOFTWARE. #include #include #include +#include +#include +#include +#include #ifndef WIN32 #include @@ -214,21 +224,54 @@ bool isTextureSerializable(const Ogre::TexturePtr& tex) // abort the rest of the export). void saveTextureAsImage(const Ogre::TexturePtr& tex, const QFileInfo& file) { + const QString saveName = MeshImporterExporter::exportTextureName( + QString::fromStdString(tex->getName())); + const QString outPath = file.path() + "/" + saveName; + + // PREFER copying the ORIGINAL texture file byte-for-byte when it's findable + // on disk. `convertToImage` re-packs the GPU texture, which THROWS for GPU + // formats Ogre can't pack back to a saveable image (e.g. the "pack to + // PF_UNKNOWN" failure on some compressed/BGR textures) — losing the texture + // entirely. A file copy is lossless and sidesteps the repack. Fall back to + // convertToImage only when the source file can't be located. + const QString texName = QString::fromStdString(tex->getName()); + QString srcPath; + // 1) Ogre resource group's on-disk location for this named texture. + try { + const Ogre::String grp = + Ogre::ResourceGroupManager::getSingleton().findGroupContainingResource( + tex->getName()); + Ogre::FileInfoListPtr fil = + Ogre::ResourceGroupManager::getSingleton().findResourceFileInfo( + grp, tex->getName()); + if (fil && !fil->empty()) + srcPath = QString::fromStdString( + fil->front().archive->getName() + "/" + fil->front().filename); + } catch (const Ogre::Exception&) { /* not in a group — try raw path */ } + // 2) The texture name may itself be an absolute/relative path. + if (srcPath.isEmpty() && QFileInfo::exists(texName)) + srcPath = texName; + + if (!srcPath.isEmpty() && QFileInfo(srcPath).isFile() + && QFileInfo(srcPath).canonicalFilePath() != QFileInfo(outPath).canonicalFilePath()) { + QFile::remove(outPath); // overwrite a stale copy + if (QFile::copy(srcPath, outPath)) + return; // lossless copy succeeded + // else fall through to the repack attempt + } + try { Ogre::Image img; tex->convertToImage(img, true); - const QString saveName = MeshImporterExporter::exportTextureName( - QString::fromStdString(tex->getName())); - const std::string outPath = (file.path() + "/" + saveName).toStdString(); - // `Ogre::Image::save` returns void; failures throw and land - // in the catch arms below. - img.save(outPath); + img.save(outPath.toStdString()); } catch (const Ogre::Exception& ex) { Ogre::LogManager::getSingleton().logError( - std::string("Failed to save texture '") + tex->getName() + "': " + ex.what()); + std::string("Failed to save texture '") + tex->getName() + "': " + ex.what() + + " (and no source file to copy)"); } catch (const std::exception& ex) { Ogre::LogManager::getSingleton().logError( - std::string("Failed to save texture '") + tex->getName() + "': " + ex.what()); + std::string("Failed to save texture '") + tex->getName() + "': " + ex.what() + + " (and no source file to copy)"); } } @@ -1171,12 +1214,35 @@ static aiScene* buildAiScene(const Ogre::Entity* entity) } // --- Animations --- + // Skeletal animations first (unchanged), then optionally one morph-weight + // animation from the mesh's "MorphAnim" clip. Both are gathered into a + // vector so the mAnimations array is sized to hold exactly what exists — + // this covers the no-skeleton + morph-only case (mAnimations was previously + // left null) and the has-skeleton + morph case (grow past the skeleton + // count) without special-casing either. + std::vector animations; if (hasSkeleton && skeleton->getNumAnimations() > 0) { - scene->mNumAnimations = skeleton->getNumAnimations(); - scene->mAnimations = new aiAnimation*[scene->mNumAnimations]; for (unsigned short ai = 0; ai < skeleton->getNumAnimations(); ++ai) - scene->mAnimations[ai] = buildAiAnimation(skeleton->getAnimation(ai)); + animations.push_back(buildAiAnimation(skeleton->getAnimation(ai))); + } + + // NOTE: morph-target SHAPES export fine (via aiMesh::mAnimMeshes above), + // but morph-WEIGHT animation over time is NOT emitted through Assimp: + // Assimp's glTF2/FBX exporters only translate aiAnimation NODE channels + // (translation/rotation/scale) — they ignore aiAnimation::mMorphMeshChannels + // entirely. An aiAnimation carrying only a morph channel also fails Assimp's + // ValidateDataStructure ("mNumChannels is 0"), and a no-op node channel + // added to satisfy it exports as a stray TRS animation on the mesh node that + // corrupts reimport (froze the viewport). So we deliberately do NOT push a + // morph-weight aiAnimation here. Round-tripping weights needs a post-write + // glTF JSON injection (target.path="weights") — tracked as a follow-up. + if (!animations.empty()) + { + scene->mNumAnimations = static_cast(animations.size()); + scene->mAnimations = new aiAnimation*[scene->mNumAnimations]; // NOSONAR — Assimp owns + for (unsigned int ai = 0; ai < scene->mNumAnimations; ++ai) + scene->mAnimations[ai] = animations[ai]; } return scene; @@ -2254,7 +2320,23 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad Ogre::SceneNode *sn; const Ogre::Entity *en; - if(!file.suffix().compare("mesh",Qt::CaseInsensitive)) + if(!file.suffix().compare("abc",Qt::CaseInsensitive)) + { + // Alembic vertex-animation cache (#519 Slice B). Delegates to + // the guarded reader; a build without -DENABLE_ALEMBIC returns + // a clear error rather than falling through to Assimp (which + // can't read .abc). The importer builds the base mesh + a + // VAT_POSE clip and creates the entity itself. + QString abcErr; + Ogre::SceneNode* abcNode = + AlembicImporter::importToScene(file.absoluteFilePath(), &abcErr); + if (!abcNode) { + Ogre::LogManager::getSingleton().logError( + "Alembic import failed: " + abcErr.toStdString()); + } + continue; + } + else if(!file.suffix().compare("mesh",Qt::CaseInsensitive)) { tryLoadSidecarMaterialScript(file); const Ogre::String meshResName = file.fileName().toStdString(); @@ -2832,19 +2914,53 @@ QString MeshImporterExporter::importFileDialogFilterFromExtensionList( const QString& spaceSeparatedDotExtensions) { const QStringList parts = spaceSeparatedDotExtensions.split(' ', Qt::SkipEmptyParts); - QStringList globs; + QStringList globs; // "*.ext" for the "All supported" row + QSet present; // lower-cased ".ext" set for lookups globs.reserve(parts.size()); for (QString ext : parts) { - ext = ext.trimmed(); - if (ext.startsWith('.')) + ext = ext.trimmed().toLower(); + if (ext.startsWith('.')) { globs.append(QLatin1Char('*') + ext); + present.insert(ext); + } } const QString allSupported = globs.join(QLatin1Char(' ')); - return QStringLiteral( - "All supported (%1);;" - "PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply);;" - "All files (*.*)") - .arg(allSupported); + + // Named per-format rows, in a sensible priority order. Each is emitted only + // if at least one of its extensions is in the valid list (so the dialog + // never advertises a format the loader can't handle). Grouped rows (e.g. + // glTF *.gltf/*.glb) show if ANY member is present. Anything valid but not + // named here still loads via "All supported" / "All files". + struct NamedFilter { const char* label; QStringList exts; }; + const QList named = { + {"FBX (*.fbx)", {".fbx"}}, + {"glTF 2.0 (*.gltf *.glb *.vrm)", {".gltf", ".glb", ".vrm"}}, + {"Wavefront OBJ (*.obj)", {".obj"}}, + {"Collada (*.dae)", {".dae"}}, + {"Ogre Mesh (*.mesh *.mesh.xml)", {".mesh"}}, + {"STL (*.stl)", {".stl"}}, + {"PLY (*.ply)", {".ply"}}, + {"3DS (*.3ds)", {".3ds"}}, + {"Blender (*.blend)", {".blend"}}, + {"DirectX X (*.x)", {".x"}}, + {"Biovision BVH (*.bvh)", {".bvh"}}, + {"LightWave (*.lwo *.lws *.lxo)", {".lwo", ".lws", ".lxo"}}, + {"Alembic vertex cache (*.abc)", {".abc"}}, + {"PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply)", {".rsd", ".tmd", ".ply"}}, + }; + + QStringList rows; + rows.reserve(named.size() + 2); + rows << QStringLiteral("All supported (%1)").arg(allSupported); + for (const NamedFilter& nf : named) { + bool any = false; + for (const QString& e : nf.exts) + if (present.contains(e)) { any = true; break; } + if (any) + rows << QString::fromLatin1(nf.label); + } + rows << QStringLiteral("All files (*.*)"); + return rows.join(QStringLiteral(";;")); } QString MeshImporterExporter::importFileDialogFilter() @@ -2852,6 +2968,401 @@ QString MeshImporterExporter::importFileDialogFilter() return importFileDialogFilterFromExtensionList(Manager::getSingleton()->getValidFileExtention()); } +namespace { + +// ─── glTF morph-target WEIGHT animation post-processor ─────────────────────── +// +// Assimp's glTF2 exporter cannot write morph-weight animation channels (its +// ExportAnimations only emits node TRS channels), so after Assimp writes the +// .gltf/.glb we inject the standard glTF `weights` animation channel into the +// JSON ourselves. See the glTF 2.0 spec — "Animations" / morph-target weights. +// +// Data source (Ogre): morph WEIGHT clips are MESH-level Ogre::Animations that +// are NOT named after a pose (a pose-named animation is a per-target SHAPE +// clip). Each weight clip carries one Ogre::VertexAnimationTrack (VAT_POSE) +// per submesh target handle; each Ogre::VertexPoseKeyFrame has a time and a +// list of {poseIndex, influence} references — influence == that target's +// weight at that time. Poses are enumerated in mesh->getPoseList() order, +// which matches the glTF morph target order Assimp exported from +// aiMesh::mAnimMeshes. +// +// Robustness: the whole helper is wrapped in try/catch and never throws out of +// the exporter. On any parse/shape failure it logs a warning and leaves the +// file exactly as Assimp wrote it (shapes still round-trip). + +// A decoded morph weight clip: sorted distinct keyframe times + a flat weight +// matrix (K rows × numTargets cols, row-major). +struct MorphWeightClip { + std::string name; + std::vector times; // K + std::vector weights; // K * numTargets +}; + +// Is `animName` a per-target SHAPE clip (named exactly a pose name)? +bool gltfIsPoseShapeClip(const Ogre::Mesh* mesh, const std::string& animName) +{ + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == animName) return true; + return false; +} + +// Enumerate the entity's mesh morph WEIGHT clips and decode each into a +// dense time/weight matrix of width `numTargets` (pose-list order). +std::vector collectMorphWeightClips(const Ogre::Entity* entity, + int numTargets) +{ + std::vector out; + if (!entity || numTargets <= 0) return out; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return out; + + for (unsigned short ai = 0; ai < mesh->getNumAnimations(); ++ai) { + Ogre::Animation* anim = mesh->getAnimation(ai); + if (!anim) continue; + const std::string nm = anim->getName(); + if (gltfIsPoseShapeClip(mesh.get(), nm)) continue; // shape clip, not a weight clip + + // Gather the sorted set of distinct keyframe times across every + // VAT_POSE track on this clip, and record each pose reference. + std::set timeSet; + for (const auto& [handle, track] : anim->_getVertexTrackList()) { + (void)handle; + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short k = 0; k < track->getNumKeyFrames(); ++k) + timeSet.insert(track->getKeyFrame(k)->getTime()); + } + if (timeSet.empty()) continue; // no keyed tracks → nothing to export + + std::vector times(timeSet.begin(), timeSet.end()); + + MorphWeightClip clip; + clip.name = nm; + clip.times = times; + clip.weights.assign(times.size() * static_cast(numTargets), 0.0f); + + // For each time build the weight vector from the pose references. + for (const auto& [handle, track] : anim->_getVertexTrackList()) { + (void)handle; + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short k = 0; k < track->getNumKeyFrames(); ++k) { + auto* kf = static_cast(track->getKeyFrame(k)); + const float t = kf->getTime(); + // Locate the row index for this time. + auto it = std::lower_bound(times.begin(), times.end(), t); + if (it == times.end()) continue; + const size_t row = static_cast(it - times.begin()); + for (const auto& ref : kf->getPoseReferences()) { + if (ref.poseIndex >= static_cast(numTargets)) + continue; // out of range for this glTF mesh — skip + clip.weights[row * static_cast(numTargets) + ref.poseIndex] + = ref.influence; + } + } + } + out.push_back(std::move(clip)); + } + return out; +} + +// Serialise a float vector little-endian into a byte buffer. +void appendFloatsLE(QByteArray& buf, const std::vector& v) +{ + const size_t base = static_cast(buf.size()); + buf.resize(static_cast(base + v.size() * sizeof(float))); + std::memcpy(buf.data() + base, v.data(), v.size() * sizeof(float)); +} + +// Find the glTF node that owns the morph mesh. Strategy: prefer the node whose +// referenced mesh's first primitive has a `targets` array of length == +// poseCount; fall back to the first node that references a mesh with any +// targets. Sets *outNumTargets. Returns the node index, or -1 on failure. +int findMorphNode(const QJsonObject& root, int poseCount, int* outNumTargets) +{ + if (!root.contains("nodes") || !root.contains("meshes")) return -1; + const QJsonArray nodes = root.value("nodes").toArray(); + const QJsonArray meshes = root.value("meshes").toArray(); + + auto targetsForMesh = [&](int meshIdx) -> int { + if (meshIdx < 0 || meshIdx >= meshes.size()) return 0; + const QJsonObject m = meshes.at(meshIdx).toObject(); + const QJsonArray prims = m.value("primitives").toArray(); + if (prims.isEmpty()) return 0; + const QJsonObject p0 = prims.at(0).toObject(); + if (!p0.contains("targets")) return 0; + return p0.value("targets").toArray().size(); + }; + + int fallbackNode = -1, fallbackTargets = 0; + for (int n = 0; n < nodes.size(); ++n) { + const QJsonObject node = nodes.at(n).toObject(); + if (!node.contains("mesh")) continue; + const int meshIdx = node.value("mesh").toInt(-1); + const int nt = targetsForMesh(meshIdx); + if (nt <= 0) continue; + if (nt == poseCount) { *outNumTargets = nt; return n; } // exact match + if (fallbackNode < 0) { fallbackNode = n; fallbackTargets = nt; } + } + if (fallbackNode >= 0) { *outNumTargets = fallbackTargets; return fallbackNode; } + return -1; +} + +// Inject morph-weight animations into an already-written .gltf/.glb file. +// No-op (leaves the file untouched) if the entity has no morph weight clips, +// the file has no morph targets, or anything fails to parse. +void injectMorphWeightAnimations(const QString& filePath, + const Ogre::Entity* entity, + bool isBinary) +{ + try { + if (!entity) return; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh || mesh->getPoseCount() == 0) return; + + // Quick early-out: does the mesh carry any weight clips at all? + // (Cheaper than a full parse; poseCount already > 0 here.) + bool anyWeightClip = false; + for (unsigned short ai = 0; ai < mesh->getNumAnimations(); ++ai) { + Ogre::Animation* a = mesh->getAnimation(ai); + if (a && !gltfIsPoseShapeClip(mesh.get(), a->getName())) { + anyWeightClip = true; + break; + } + } + if (!anyWeightClip) return; + + // ── Read the file (GLB: split header/JSON/BIN; glTF: whole file) ── + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly)) return; + const QByteArray whole = f.readAll(); + f.close(); + + QByteArray jsonBytes; + QByteArray binChunk; // GLB buffer 0 (copied verbatim) + bool haveBinChunk = false; + + if (isBinary) { + // GLB layout: 12-byte header, then chunks (4-byte length + 4-byte + // type + data). JSON chunk type 0x4E4F534A ("JSON"), BIN chunk + // type 0x004E4942 ("BIN\0"). + if (whole.size() < 12) return; + const auto rdU32 = [&](int off) -> quint32 { + return static_cast(whole[off]) + | (static_cast(whole[off + 1]) << 8) + | (static_cast(whole[off + 2]) << 16) + | (static_cast(whole[off + 3]) << 24); + }; + const quint32 magic = rdU32(0); + if (magic != 0x46546C67u /*'glTF'*/) return; + int off = 12; + while (off + 8 <= whole.size()) { + const quint32 clen = rdU32(off); + const quint32 ctype = rdU32(off + 4); + const int dataOff = off + 8; + if (dataOff + static_cast(clen) > whole.size()) return; + const QByteArray data = whole.mid(dataOff, static_cast(clen)); + if (ctype == 0x4E4F534Au) { // JSON + jsonBytes = data; + } else if (ctype == 0x004E4942u) { // BIN + binChunk = data; + haveBinChunk = true; + } + off = dataOff + static_cast(clen); + } + if (jsonBytes.isEmpty()) return; + } else { + jsonBytes = whole; + } + + QJsonParseError perr; + QJsonDocument doc = QJsonDocument::fromJson(jsonBytes, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) return; + QJsonObject root = doc.object(); + + // ── Locate the morph node + authoritative target count ── + int numTargets = 0; + const int nodeIdx = findMorphNode(root, static_cast(mesh->getPoseCount()), + &numTargets); + if (nodeIdx < 0 || numTargets <= 0) return; + + // ── Decode the weight clips against numTargets ── + std::vector clips = collectMorphWeightClips(entity, numTargets); + if (clips.empty()) return; + + // ── Build the extra data-URI buffer + accessors/bufferViews ── + // All weight data lives in a NEW buffer encoded as a base64 data URI, + // so we never touch the existing GLB BIN chunk (buffer 0) or the + // existing .gltf buffers — much simpler and safe. + QByteArray extraBuf; + + QJsonArray accessors = root.value("accessors").toArray(); + QJsonArray bufferViews = root.value("bufferViews").toArray(); + QJsonArray buffers = root.value("buffers").toArray(); + QJsonArray animations = root.value("animations").toArray(); + + const int newBufferIndex = buffers.size(); // appended below + + for (const MorphWeightClip& clip : clips) { + const int K = static_cast(clip.times.size()); + if (K == 0) continue; + + // — input (times) accessor — + const int timesByteOffset = extraBuf.size(); + appendFloatsLE(extraBuf, clip.times); + const int timesBv = bufferViews.size(); + { + QJsonObject bv; + bv["buffer"] = newBufferIndex; + bv["byteOffset"] = timesByteOffset; + bv["byteLength"] = static_cast(clip.times.size() * sizeof(float)); + bufferViews.append(bv); + } + float tmin = clip.times.front(), tmax = clip.times.front(); + for (float t : clip.times) { tmin = std::min(tmin, t); tmax = std::max(tmax, t); } + const int inputAccessor = accessors.size(); + { + QJsonObject acc; + acc["bufferView"] = timesBv; + acc["byteOffset"] = 0; + acc["componentType"] = 5126; // FLOAT + acc["count"] = K; + acc["type"] = "SCALAR"; + acc["min"] = QJsonArray{ static_cast(tmin) }; + acc["max"] = QJsonArray{ static_cast(tmax) }; + accessors.append(acc); + } + + // — output (weights) accessor — + const int weightsByteOffset = extraBuf.size(); + appendFloatsLE(extraBuf, clip.weights); + const int weightsBv = bufferViews.size(); + { + QJsonObject bv; + bv["buffer"] = newBufferIndex; + bv["byteOffset"] = weightsByteOffset; + bv["byteLength"] = static_cast(clip.weights.size() * sizeof(float)); + bufferViews.append(bv); + } + const int outputAccessor = accessors.size(); + { + QJsonObject acc; + acc["bufferView"] = weightsBv; + acc["byteOffset"] = 0; + acc["componentType"] = 5126; // FLOAT + acc["count"] = K * numTargets; + acc["type"] = "SCALAR"; + accessors.append(acc); + } + + // — animation entry (sampler index is animation-local) — + QJsonObject sampler; + sampler["input"] = inputAccessor; + sampler["output"] = outputAccessor; + sampler["interpolation"] = "LINEAR"; + + QJsonObject targetObj; + targetObj["node"] = nodeIdx; + targetObj["path"] = "weights"; + + QJsonObject channel; + channel["sampler"] = 0; // first (only) sampler within this animation + channel["target"] = targetObj; + + QJsonObject animObj; + animObj["name"] = QString::fromStdString(clip.name); + animObj["samplers"] = QJsonArray{ sampler }; + animObj["channels"] = QJsonArray{ channel }; + animations.append(animObj); + } + + if (extraBuf.isEmpty()) return; // nothing to inject after all + + // — append the data-URI buffer — + { + QJsonObject buf; + const QByteArray b64 = extraBuf.toBase64(); + buf["uri"] = QStringLiteral("data:application/octet-binary;base64,") + + QString::fromLatin1(b64); + buf["byteLength"] = extraBuf.size(); + buffers.append(buf); + } + + root["accessors"] = accessors; + root["bufferViews"] = bufferViews; + root["buffers"] = buffers; + root["animations"] = animations; + + // ── Re-serialise ── + const QByteArray newJson = + QJsonDocument(root).toJson(QJsonDocument::Compact); + + QFile out(filePath); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) return; + + if (isBinary) { + // Re-emit the GLB container: header + JSON chunk (space-padded to + // 4-byte alignment) + the ORIGINAL unchanged BIN chunk (buffer 0). + // The injected weight data lives in the data-URI buffer inside the + // JSON, so the BIN chunk is copied byte-for-byte. + QByteArray jsonPadded = newJson; + while (jsonPadded.size() % 4 != 0) jsonPadded.append(' '); + + const auto wrU32 = [&](QByteArray& b, quint32 v) { + b.append(static_cast(v & 0xFF)); + b.append(static_cast((v >> 8) & 0xFF)); + b.append(static_cast((v >> 16) & 0xFF)); + b.append(static_cast((v >> 24) & 0xFF)); + }; + + // BIN chunk data padded to 4-byte alignment with zero bytes. + QByteArray binPadded; + if (haveBinChunk) { + binPadded = binChunk; + while (binPadded.size() % 4 != 0) binPadded.append('\0'); + } + + quint32 total = 12; // header + total += 8 + static_cast(jsonPadded.size()); // JSON chunk + if (haveBinChunk) + total += 8 + static_cast(binPadded.size()); // BIN chunk + + QByteArray glb; + wrU32(glb, 0x46546C67u); // magic 'glTF' + wrU32(glb, 2u); // version + wrU32(glb, total); // total length + + wrU32(glb, static_cast(jsonPadded.size())); + wrU32(glb, 0x4E4F534Au); // 'JSON' + glb.append(jsonPadded); + + if (haveBinChunk) { + wrU32(glb, static_cast(binPadded.size())); + wrU32(glb, 0x004E4942u); // 'BIN\0' + glb.append(binPadded); + } + + out.write(glb); + } else { + out.write(newJson); + } + out.close(); + + SentryReporter::addBreadcrumb( + QStringLiteral("file.export"), + QStringLiteral("Injected %1 morph-weight animation(s) into %2") + .arg(clips.size()).arg(filePath)); + } catch (const std::exception& ex) { + Ogre::LogManager::getSingleton().logWarning( + std::string("injectMorphWeightAnimations failed (left file as-is): ") + + ex.what()); + } catch (...) { + Ogre::LogManager::getSingleton().logWarning( + "injectMorphWeightAnimations failed with unknown exception " + "(left file as-is)"); + } +} + +} // namespace + QString MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, QWidget* parent) { if(!_sn) @@ -3444,6 +3955,15 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // For FBX we aim for a single-file export (embedded textures), so skip sidecars. if (_format != "FBX Binary (*.fbx)") exportMaterial(e, file); + + // Assimp's glTF2 exporter drops morph-target WEIGHT animation + // channels. Post-process the written file to inject the + // standard glTF `weights` channels from Ogre's mesh weight + // clips. No-op when the mesh has no morph weight animation; + // never throws out of the exporter. + if (formatId == "gltf2" || formatId == "glb2") + injectMorphWeightAnimations(file.filePath(), e, + /*isBinary=*/formatId == "glb2"); } delete scene; diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 48cd97819..581a8c67c 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -590,8 +590,29 @@ TEST(MeshImporterExporterStandaloneTest, ExportFileDialogFilter_ContainsAllForma TEST(MeshImporterExporterStandaloneTest, ImportFileDialogFilterFromExtensionList_BuildsRows) { + // Per-format rows are emitted only for extensions actually present. With + // just .fbx/.obj, the named FBX + OBJ rows show, but Alembic / PlayStation + // (whose extensions aren't in the list) do not. QString f = MeshImporterExporter::importFileDialogFilterFromExtensionList(QStringLiteral(".fbx .obj")); EXPECT_TRUE(f.startsWith(QStringLiteral("All supported (*.fbx *.obj);;"))); + EXPECT_TRUE(f.contains(QStringLiteral("FBX (*.fbx)"))); + EXPECT_TRUE(f.contains(QStringLiteral("Wavefront OBJ (*.obj)"))); + EXPECT_FALSE(f.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); + EXPECT_FALSE(f.contains(QStringLiteral("PlayStation"))); + EXPECT_TRUE(f.endsWith(QStringLiteral("All files (*.*)"))); +} + +TEST(MeshImporterExporterStandaloneTest, ImportFileDialogFilterFromExtensionList_NamedRowsGatedByExtension) +{ + // Full valid list → the Alembic + PlayStation named rows appear, and the + // per-format rows are present. Order: All supported first, All files last. + QString f = MeshImporterExporter::importFileDialogFilterFromExtensionList( + QStringLiteral(".fbx .obj .gltf .glb .dae .stl .ply .abc .rsd .tmd")); + EXPECT_TRUE(f.startsWith(QStringLiteral("All supported ("))); + EXPECT_TRUE(f.contains(QStringLiteral("*.abc"))); // in All supported + EXPECT_TRUE(f.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); + EXPECT_TRUE(f.contains(QStringLiteral("glTF 2.0 (*.gltf *.glb *.vrm)"))); + EXPECT_TRUE(f.contains(QStringLiteral("Collada (*.dae)"))); EXPECT_TRUE(f.contains(QStringLiteral("PlayStation RSD / TMD / Psy-Q PLY (*.rsd *.tmd *.ply)"))); EXPECT_TRUE(f.endsWith(QStringLiteral("All files (*.*)"))); } @@ -2014,6 +2035,8 @@ TEST_F(MeshImporterExporterTest, ImportFileDialogFilter_UsesManagerExtensions) EXPECT_FALSE(filter.isEmpty()); EXPECT_TRUE(filter.contains(QStringLiteral(".obj"))); EXPECT_TRUE(filter.contains(QStringLiteral("PlayStation RSD / TMD / Psy-Q PLY"))); + // .abc is in Manager's valid-extension list → the Alembic row shows. + EXPECT_TRUE(filter.contains(QStringLiteral("Alembic vertex cache (*.abc)"))); } TEST_F(MeshImporterExporterTest, Importer_PlayStationTmd_CreatesEntity) diff --git a/src/MorphAnimationManager.cpp b/src/MorphAnimationManager.cpp index 3a1600c68..555941f44 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -10,8 +10,9 @@ The MIT License #include "MorphAnimationManager.h" +#include "AnimationControlController.h" +#include "PropertiesPanelController.h" #include "GamificationManager.h" - #include "EditModeController.h" #include "EditableMesh.h" #include "SelectionSet.h" @@ -22,8 +23,12 @@ The MIT License #include #include +#include #include +#include #include +#include +#include #include #include @@ -55,7 +60,19 @@ MorphAnimationManager* MorphAnimationManager::instance() MorphAnimationManager* MorphAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) { assertMainThread(); - return instance(); + auto* inst = instance(); + // This is a process-wide singleton shared across EVERY QQuickWidget's + // QQmlEngine (Inspector, dope sheet, curve editor — each has its own + // engine). Without CppOwnership, an engine treats the returned object as + // JavaScript-owned and its GC may delete the shared s_instance out from + // under the other engines (and the C++ callers of instance()), leaving a + // dangling pointer → SIGSEGV. Every sibling singleton (PropertiesPanel- + // Controller, AnimationControlController, ThemeManager, …) pins ownership + // here; this one must too. Missing this only became a live crash once the + // dope sheet started importing PropertiesPanel + binding to this singleton + // (a SECOND engine wrapping the same object). + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; } void MorphAnimationManager::kill() @@ -66,7 +83,9 @@ void MorphAnimationManager::kill() s_instance = nullptr; } -MorphAnimationManager::MorphAnimationManager(QObject* parent) : QObject(parent) +MorphAnimationManager::MorphAnimationManager(QObject* parent) + : QObject(parent), + m_activeMorphClip(QString::fromUtf8(kWeightClipName)) { if (auto* sel = SelectionSet::getSingleton()) { connect(sel, &SelectionSet::selectionChanged, @@ -165,6 +184,428 @@ bool MorphAnimationManager::setWeightForSelection(const QString& name, double w) return setWeight(ents.first(), name, static_cast(w)); } +const char* MorphAnimationManager::kWeightClipName = "MorphAnim"; + +namespace { + +// Find the pose index (into mesh->getPoseList()) for the first pose named +// `name`. -1 if none. Weight keyframing references the pose by this index. +int poseIndexForName(Ogre::Mesh* mesh, const std::string& name) +{ + const auto& poses = mesh->getPoseList(); + for (unsigned short i = 0; i < poses.size(); ++i) + if (poses[i] && poses[i]->getName() == name) return i; + return -1; +} + +// The weight-animation track for a target lives on the named `clip`, keyed on +// the pose's target submesh handle. Fetch-or-create the clip + track. Returns +// nullptr if the pose doesn't exist. +Ogre::VertexAnimationTrack* weightTrackFor(Ogre::Mesh* mesh, const std::string& name, + const std::string& clip, bool create) +{ + const int pi = poseIndexForName(mesh, name); + if (pi < 0) return nullptr; + const unsigned short handle = mesh->getPoseList()[pi]->getTarget(); + + Ogre::Animation* anim = mesh->hasAnimation(clip) + ? mesh->getAnimation(clip) + : (create ? mesh->createAnimation(clip, 0.0f) : nullptr); + if (!anim) return nullptr; + + if (anim->hasVertexTrack(handle)) + return anim->getVertexTrack(handle); + if (!create) return nullptr; + return anim->createVertexTrack(handle, Ogre::VAT_POSE); +} + +// Is `animName` a per-target SHAPE clip (named exactly a pose name)? Those are +// not user-facing "morph clips"; the weight clips are everything else that +// carries a VAT_POSE track and isn't a pose name. +bool isPoseShapeClip(Ogre::Mesh* mesh, const std::string& animName) +{ + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == animName) return true; + return false; +} + +} // namespace + +bool MorphAnimationManager::setMorphWeightKeyframe(const QString& name, + double time, double weight) +{ + assertMainThread(); + if (name.isEmpty() || time < 0.0) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return false; + + const std::string clip = m_activeMorphClip.toStdString(); + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return false; + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), clip, true); + if (!track) return false; + + const float t = static_cast(time); + const float w = std::clamp(static_cast(weight), 0.0f, 1.0f); + + // Update in place if a keyframe already exists at ~t, else create one. + Ogre::VertexPoseKeyFrame* kf = nullptr; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* k = static_cast(track->getKeyFrame(i)); + if (std::abs(k->getTime() - t) < 1e-4f) { kf = k; break; } + } + if (!kf) kf = track->createVertexPoseKeyFrame(t); + + // A VAT_POSE keyframe holds a LIST of pose references. Targets on the same + // submesh share one track, so a keyframe at time t may already reference + // OTHER targets' poses — clearing them all would clobber a sibling target + // keyed at the same time. Update only THIS pose's reference (or add it), + // leaving the others intact. updatePoseReference is add-or-update in Ogre. + kf->updatePoseReference(static_cast(pi), w); + + // Extend the clip length to cover the new time. + Ogre::Animation* anim = mesh->getAnimation(clip); + if (anim && t > anim->getLength()) + anim->setLength(t); + + // Refresh the entity's animation-state mirror so the new clip is playable. + entity->refreshAvailableAnimationState(); + emit morphTargetsChanged(); + // The clip only becomes a playable AnimationState once it has a track + // (created on the first key here) — signal the clip list so the Animation + // Mode list picks it up now, not just on create/delete/rename. + emit morphClipsChanged(); + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("key weight '%1' @%2 = %3").arg(name).arg(t).arg(w)); + return true; +} + +bool MorphAnimationManager::clearMorphWeightKeyframe(const QString& name, double time) +{ + assertMainThread(); + if (name.isEmpty()) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return false; + + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return false; + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); + if (!track) return false; + const float t = static_cast(time); + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::abs(kf->getTime() - t) >= 1e-4f) continue; + // Remove only THIS pose's reference (siblings on the shared track keyed + // at the same time must survive). Drop the whole keyframe only when no + // pose references remain. + bool hasThis = false; + for (const auto& ref : kf->getPoseReferences()) + if (ref.poseIndex == static_cast(pi)) { hasThis = true; break; } + if (!hasThis) return false; + kf->removePoseReference(static_cast(pi)); + if (kf->getPoseReferences().empty()) + track->removeKeyFrame(i); + emit morphTargetsChanged(); + return true; + } + return false; +} + +double MorphAnimationManager::morphWeightAt(const QString& name, double time) const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return -1.0; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return -1.0; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return -1.0; + + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return -1.0; + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); + if (!track) return -1.0; + const float t = static_cast(time); + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + if (std::abs(kf->getTime() - t) >= 1e-4f) continue; + for (const auto& ref : kf->getPoseReferences()) + if (ref.poseIndex == static_cast(pi)) + return static_cast(ref.influence); + } + return -1.0; +} + +bool MorphAnimationManager::moveMorphWeightKeyframe(const QString& name, + double oldTime, double newTime) +{ + assertMainThread(); + if (name.isEmpty()) return false; + if (std::abs(oldTime - newTime) < 1e-4) return false; + if (newTime < 0.0) return false; + + // Preserve the weight from the source key; reject if there's already a key + // at the destination (avoid silently merging two keys). + const double w = morphWeightAt(name, oldTime); + if (w < 0.0) return false; // no source key + if (morphWeightAt(name, newTime) >= 0.0) return false; // destination occupied + + if (!clearMorphWeightKeyframe(name, oldTime)) return false; + return setMorphWeightKeyframe(name, newTime, w); +} + +bool MorphAnimationManager::activateWeightClip() +{ + assertMainThread(); + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + Ogre::MeshPtr mesh = entity->getMesh(); + const std::string clip = m_activeMorphClip.toStdString(); + if (!mesh || !mesh->hasAnimation(clip)) return false; + + // Make sure the entity mirrors the clip as a playable AnimationState and + // enable it (weight tracks only apply while the state is enabled). Disable + // any OTHER morph clip so two emotion clips don't fight over the same poses. + entity->refreshAvailableAnimationState(); + if (auto* states = entity->getAllAnimationStates()) { + for (const auto& [nm, st] : states->getAnimationStates()) + if (!isPoseShapeClip(mesh.get(), nm) && nm != clip + && mesh->hasAnimation(nm)) + st->setEnabled(false); + } + if (entity->hasAnimationState(clip)) { + Ogre::AnimationState* st = entity->getAnimationState(clip); + st->setEnabled(true); + st->setLoop(true); + } + + // Select it in the Animation Control panel so the timeline slider scrubs it + // (its length + slider maximum come from the mesh Animation now). + if (auto* acc = AnimationControlController::instance()) + acc->selectAnimation(QString::fromStdString(entity->getName()), + m_activeMorphClip); + return true; +} + +QVariantList MorphAnimationManager::morphWeightKeyframeTimes(const QString& name) const +{ + QVariantList out; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return out; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return out; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return out; + + const int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return out; + Ogre::VertexAnimationTrack* track = + weightTrackFor(mesh.get(), name.toStdString(), m_activeMorphClip.toStdString(), false); + if (!track) return out; + // Targets on the same submesh SHARE one VAT_POSE track, so a keyframe on the + // track may belong to a DIFFERENT target. Only report times of keyframes + // that actually reference THIS pose — otherwise "JawOpen" would report the + // "Smile" keys on the shared track (matches the dope-sheet's per-pose rows). + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + auto* kf = static_cast(track->getKeyFrame(i)); + bool refsThisPose = false; + for (const auto& ref : kf->getPoseReferences()) + if (ref.poseIndex == static_cast(pi)) { refsThisPose = true; break; } + if (refsThisPose) + out.append(static_cast(kf->getTime())); + } + return out; +} + +// ── Morph clip management (multiple named weight clips) ────────────────────── + +void MorphAnimationManager::setActiveMorphClip(const QString& name) +{ + if (name.isEmpty() || name == m_activeMorphClip) return; + m_activeMorphClip = name; + emit morphClipsChanged(); + emit morphTargetsChanged(); // dope sheet reads the active clip's keys +} + +QStringList MorphAnimationManager::morphClips() const +{ + QStringList out; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return out; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return out; + Ogre::MeshPtr mesh = ents.first()->getMesh(); + if (!mesh) return out; + + // A morph (weight) clip = any MESH-level Animation that is NOT a per-target + // shape clip (those are named exactly a pose name). Skeletal clips live on + // the skeleton, not the mesh, so they never appear here. We deliberately do + // NOT require a VAT_POSE track: a freshly-created clip has no tracks until + // its first keyframe, and it must still show in the dropdown. + for (unsigned short i = 0; i < mesh->getNumAnimations(); ++i) { + Ogre::Animation* a = mesh->getAnimation(i); + if (!a) continue; + const std::string nm = a->getName(); + if (isPoseShapeClip(mesh.get(), nm)) continue; + out << QString::fromStdString(nm); + } + + // Auto-adopt an existing clip as active when the current active clip isn't + // on this mesh (e.g. right after IMPORTING a model whose clip is named + // "Sniff" while the app default is "MorphAnim"). Without this the dope sheet + // + keying would target a non-existent clip and show nothing until the user + // manually picked the imported clip from the dropdown. const_cast is safe: + // this only mutates the transient active-clip selector, not scene data. + if (!out.isEmpty() && !out.contains(m_activeMorphClip)) { + auto* self = const_cast(this); + self->m_activeMorphClip = out.first(); + // Defer the signal so we don't emit inside a const getter the QML may be + // mid-binding on; a queued emit refreshes the dropdown + dope sheet. + QMetaObject::invokeMethod(self, [self]() { + emit self->morphClipsChanged(); + emit self->morphTargetsChanged(); + }, Qt::QueuedConnection); + } + return out; +} + +bool MorphAnimationManager::createMorphClip(const QString& name) +{ + assertMainThread(); + if (name.trimmed().isEmpty()) return false; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) return false; + // A morph clip animates existing morph TARGETS. With no poses there is + // nothing to key, and creating an empty VAT-less animation on a plain mesh + // (e.g. an unmorphed OBJ) then refreshing/rendering its AnimationState is + // what crashed. Require at least one target first — the user sculpts +Add + // before making clips. (m_activeMorphClip still tracks the intended name.) + if (mesh->getPoseCount() == 0) + return false; + const std::string nm = name.trimmed().toStdString(); + if (mesh->hasAnimation(nm)) return false; // name collision + + mesh->createAnimation(nm, 0.0f); // empty; tracks added on first key + entity->refreshAvailableAnimationState(); + m_activeMorphClip = name.trimmed(); + emit morphClipsChanged(); + emit morphTargetsChanged(); + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("create morph clip '%1'").arg(name.trimmed())); + return true; +} + +bool MorphAnimationManager::deleteMorphClip(const QString& name) +{ + assertMainThread(); + if (name.isEmpty()) return false; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh || !mesh->hasAnimation(name.toStdString())) return false; + if (isPoseShapeClip(mesh.get(), name.toStdString())) return false; // that's a target + + // Stop playback and drop the state before removing the animation. + if (auto* ppc = PropertiesPanelController::instance()) ppc->setPlaying(false); + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(name.toStdString())) + states->getAnimationState(name.toStdString())->setEnabled(false); + } + mesh->removeAnimation(name.toStdString()); + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(name.toStdString())) + states->removeAnimationState(name.toStdString()); + } + entity->refreshAvailableAnimationState(); + + // If the active clip was deleted, fall back to another (or the default). + if (m_activeMorphClip == name) { + const QStringList remaining = morphClips(); + m_activeMorphClip = remaining.isEmpty() + ? QString::fromUtf8(kWeightClipName) : remaining.first(); + } + emit morphClipsChanged(); + emit morphTargetsChanged(); + return true; +} + +bool MorphAnimationManager::renameMorphClip(const QString& oldName, const QString& newName) +{ + assertMainThread(); + if (oldName.isEmpty() || newName.trimmed().isEmpty() || oldName == newName) + return false; + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + Ogre::MeshPtr mesh = entity->getMesh(); + const std::string on = oldName.toStdString(); + const std::string nn = newName.trimmed().toStdString(); + if (!mesh || !mesh->hasAnimation(on) || mesh->hasAnimation(nn)) return false; + if (isPoseShapeClip(mesh.get(), on)) return false; // that's a target, not a clip + + // Stop playback + disable the old clip's state before recreating/removing + // the animation — the render loop reads the clip's tracks every frame, so + // mutating it while its AnimationState is active leaves a stale reference + // (mirrors the delete path). + if (auto* ppc = PropertiesPanelController::instance()) ppc->setPlaying(false); + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(on)) + states->getAnimationState(on)->setEnabled(false); + } + + // Rebuild the Animation under the new name copying every VAT_POSE track + + // keyframe + pose reference, then drop the old one. (Ogre has no rename.) + Ogre::Animation* src = mesh->getAnimation(on); + Ogre::Animation* dst = mesh->createAnimation(nn, src->getLength()); + for (const auto& [handle, srcTrack] : src->_getVertexTrackList()) { + if (!srcTrack || srcTrack->getAnimationType() != Ogre::VAT_POSE) continue; + Ogre::VertexAnimationTrack* dstTrack = + dst->createVertexTrack(handle, Ogre::VAT_POSE); + for (unsigned short k = 0; k < srcTrack->getNumKeyFrames(); ++k) { + auto* skf = static_cast(srcTrack->getKeyFrame(k)); + auto* dkf = dstTrack->createVertexPoseKeyFrame(skf->getTime()); + for (const auto& ref : skf->getPoseReferences()) + dkf->addPoseReference(ref.poseIndex, ref.influence); + } + } + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(on)) + states->removeAnimationState(on); + } + mesh->removeAnimation(on); + entity->refreshAvailableAnimationState(); + if (m_activeMorphClip == oldName) m_activeMorphClip = newName.trimmed(); + emit morphClipsChanged(); + emit morphTargetsChanged(); + return true; +} + namespace { // Walk the per-submesh edited positions on the EditableMesh and diff @@ -206,7 +647,9 @@ std::vector capturePoseSlicesFromEdit( if (bindPositions.size() != subs[s].vertices.size()) continue; MorphPoseSlice slice; - slice.submeshHandle = static_cast(s + 1); + // Shared geometry poses target handle 0; per-submesh use 1-based. + slice.submeshHandle = subs[s].usesSharedVertices + ? 0 : static_cast(s + 1); for (size_t vi = 0; vi < bindPositions.size(); ++vi) { const Ogre::Vector3 delta = subs[s].vertices[vi].position - bindPositions[vi]; @@ -318,3 +761,56 @@ bool MorphAnimationManager::deleteMorphTarget(const QString& name) emit morphTargetsChanged(); return true; } + +bool MorphAnimationManager::moveMorphTarget(const QString& name, int delta) +{ + assertMainThread(); + if (name.isEmpty() || delta == 0) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + + const QStringList order = morphTargetsFor(entity); + const int from = order.indexOf(name); + if (from < 0) return false; + int to = from + delta; + if (to < 0) to = 0; + if (to > order.size() - 1) to = order.size() - 1; + if (to == from) return false; // already at the edge → no-op + + return moveMorphTargetToIndex(name, to); +} + +bool MorphAnimationManager::moveMorphTargetToIndex(const QString& name, int toIndex) +{ + assertMainThread(); + if (name.isEmpty()) return false; + + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + auto ents = sel->getResolvedEntities(); + if (ents.isEmpty() || !ents.first()) return false; + Ogre::Entity* entity = ents.first(); + + const QStringList oldOrder = morphTargetsFor(entity); + const int from = oldOrder.indexOf(name); + if (from < 0) return false; + int to = toIndex; + if (to < 0) to = 0; + if (to > oldOrder.size() - 1) to = oldOrder.size() - 1; + if (to == from) return false; + + QStringList newOrder = oldOrder; + newOrder.move(from, to); + if (newOrder == oldOrder) return false; + + auto* undo = UndoManager::getSingleton(); + if (!undo) return false; + undo->push(new ReorderMorphTargetsCommand(entity, oldOrder, newOrder)); + + emit morphTargetsChanged(); + return true; +} diff --git a/src/MorphAnimationManager.h b/src/MorphAnimationManager.h index 5b2e759bd..b557df797 100644 --- a/src/MorphAnimationManager.h +++ b/src/MorphAnimationManager.h @@ -70,6 +70,66 @@ class MorphAnimationManager : public QObject Q_INVOKABLE double weightForSelection(const QString& name) const; Q_INVOKABLE bool setWeightForSelection(const QString& name, double w); + /// Weight keyframing over time (Slice 2, #519). Morph targets are the shared + /// SHAPES (smile, browRaise, jawOpen…); a "morph clip" is a named mesh + /// Animation that keyframes those shapes' weights over time (smile / angry / + /// surprised). A clip holds one VAT_POSE track per target's pose; each + /// keyframe references the pose at the influence (= weight) at that time. + /// Each clip exports as a separate glTF morph-weights animation. Multiple + /// clips share the same targets. Distinct from the static per-target + /// Animation (named exactly the target name) that only carries the shape. + + /// The currently-active morph clip that keyframe edits write to (default + /// `kWeightClipName`). Every keyframe method below operates on THIS clip. + Q_PROPERTY(QString activeMorphClip READ activeMorphClip WRITE setActiveMorphClip + NOTIFY morphClipsChanged) + QString activeMorphClip() const { return m_activeMorphClip; } + void setActiveMorphClip(const QString& name); + + /// Named morph (weight) clips on the selected entity — mesh VAT_POSE + /// animations that are NOT per-target shape clips. For the clip dropdown. + Q_INVOKABLE QStringList morphClips() const; + + /// Create an empty morph clip named `name` and make it active. Returns false + /// if the name is empty / already exists / no selection. + Q_INVOKABLE bool createMorphClip(const QString& name); + + /// Delete a morph clip (its Animation + AnimationState). Returns false on + /// no-op. Does NOT touch the shared targets/poses. + Q_INVOKABLE bool deleteMorphClip(const QString& name); + + /// Rename a morph clip. Rejects the reserved default name collisions. + Q_INVOKABLE bool renameMorphClip(const QString& oldName, const QString& newName); + + /// Record `weight` for target `name` at `time` on the ACTIVE morph clip + /// (creates the clip/track/keyframe as needed, updates in place otherwise). + /// Extends the clip length to cover `time`. Returns false on no-op. + Q_INVOKABLE bool setMorphWeightKeyframe(const QString& name, double time, double weight); + + /// Remove the active clip's weight keyframe for `name` at (approx) `time`. + Q_INVOKABLE bool clearMorphWeightKeyframe(const QString& name, double time); + + /// Move a weight keyframe from `oldTime` to `newTime` on the active clip, + /// preserving its weight (dope-sheet drag). False on no-op/not-found/collision. + Q_INVOKABLE bool moveMorphWeightKeyframe(const QString& name, + double oldTime, double newTime); + + /// The weight stored at (approx) `time` for `name` on the active clip, or -1. + Q_INVOKABLE double morphWeightAt(const QString& name, double time) const; + + /// Active clip's keyframe times (seconds) for `name`, ascending. Empty if + /// none. For the dope sheet. + Q_INVOKABLE QVariantList morphWeightKeyframeTimes(const QString& name) const; + + /// Default morph-clip name (used when no clip is named explicitly + the + /// first clip created). Also the glTF export fallback name. + static const char* kWeightClipName; + + /// Make the ACTIVE morph clip the playable animation on the selected entity: + /// select it in the Animation Control panel + enable its AnimationState so + /// the timeline scrubs/plays the keyed weights. No-op if it doesn't exist. + Q_INVOKABLE bool activateWeightClip(); + /// Authoring (slice A3). All three push a QUndoCommand on the /// shared UndoManager stack so Ctrl+Z reverses the change. All /// return false on no-op (entity missing, name collision, etc.). @@ -93,6 +153,16 @@ class MorphAnimationManager : public QObject /// Animation, and resets any AnimationState that referenced it. Q_INVOKABLE bool deleteMorphTarget(const QString& name); + /// Reorder morph targets: move `name` by `delta` positions in the display + /// order (-1 = up, +1 = down; larger jumps clamp to the ends). Pushes an + /// undoable ReorderMorphTargetsCommand. Returns false if the move is a + /// no-op (already at the edge / name not found / no selection). + Q_INVOKABLE bool moveMorphTarget(const QString& name, int delta); + + /// Reorder by absolute index: move `name` to position `toIndex` in the + /// display order (for drag-and-drop). Returns false on a no-op. + Q_INVOKABLE bool moveMorphTargetToIndex(const QString& name, int toIndex); + signals: /// Emitted when a morph weight on any entity is changed via /// `setWeight`. QML uses this to re-fetch values. @@ -100,11 +170,17 @@ class MorphAnimationManager : public QObject /// Emitted when the morph-target list visible to the Inspector /// could have changed (selection moved, scene reloaded, etc.). void morphTargetsChanged(); + /// Emitted when the morph-clip list or the active clip changes. + void morphClipsChanged(); private: explicit MorphAnimationManager(QObject* parent = nullptr); ~MorphAnimationManager() override; + // The clip that keyframe edits target. Defaults to kWeightClipName so + // existing single-clip behaviour is preserved until the user makes more. + QString m_activeMorphClip; + static MorphAnimationManager* s_instance; }; diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp index a27ee039f..cefbee9ea 100644 --- a/src/MorphAnimationManager_test.cpp +++ b/src/MorphAnimationManager_test.cpp @@ -249,6 +249,130 @@ TEST_F(MorphAnimationManagerSceneTest, SelectionDrivenAccessorsResolveFirstEntit EXPECT_NEAR(m->weightForSelection(QStringLiteral("Smile")), 0.6, 1e-4); } +TEST_F(MorphAnimationManagerSceneTest, WeightKeyframingBuildsMorphAnimClip) { + auto mesh = createMorphTestMesh("Morph_KeyClip"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_KeyClipEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + // No weight keyframes initially. + EXPECT_TRUE(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).isEmpty()); + + // Key Smile at t=0 (w=0) and t=1 (w=1) → the shared "MorphAnim" clip. + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 0.0, 0.0)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 1.0, 1.0)); + + ASSERT_TRUE(mesh->hasAnimation(MorphAnimationManager::kWeightClipName)); + Ogre::Animation* clip = mesh->getAnimation(MorphAnimationManager::kWeightClipName); + EXPECT_NEAR(clip->getLength(), 1.0, 1e-4); + + QVariantList times = m->morphWeightKeyframeTimes(QStringLiteral("Smile")); + ASSERT_EQ(times.size(), 2); + EXPECT_NEAR(times[0].toDouble(), 0.0, 1e-4); + EXPECT_NEAR(times[1].toDouble(), 1.0, 1e-4); + + // Updating an existing time in place doesn't add a keyframe. + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 1.0, 0.5)); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 2); + + // Clearing removes it. + EXPECT_TRUE(m->clearMorphWeightKeyframe(QStringLiteral("Smile"), 0.0)); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 1); + // Unknown target / time → no-op false. + EXPECT_FALSE(m->setMorphWeightKeyframe(QStringLiteral("Nope"), 0.0, 1.0)); + EXPECT_FALSE(m->clearMorphWeightKeyframe(QStringLiteral("Smile"), 5.0)); + sel->clear(); +} + +TEST_F(MorphAnimationManagerSceneTest, MultipleNamedMorphClipsShareTargets) { + auto mesh = createMorphTestMesh("Morph_MultiClip"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_MultiClipEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + m->setActiveMorphClip(QStringLiteral("smile")); + + // Two distinct clips over the SAME shared targets (JawOpen, Smile). + ASSERT_TRUE(m->createMorphClip(QStringLiteral("smile"))); + EXPECT_EQ(m->activeMorphClip(), QStringLiteral("smile")); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 0.0, 0.0)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 1.0, 1.0)); + + ASSERT_TRUE(m->createMorphClip(QStringLiteral("angry"))); + EXPECT_EQ(m->activeMorphClip(), QStringLiteral("angry")); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.0, 0.0)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.5, 1.0)); + + // Both clips exist as separate mesh animations; targets are unchanged. + QStringList clips = m->morphClips(); + EXPECT_TRUE(clips.contains(QStringLiteral("smile"))); + EXPECT_TRUE(clips.contains(QStringLiteral("angry"))); + EXPECT_TRUE(mesh->hasAnimation("smile")); + EXPECT_TRUE(mesh->hasAnimation("angry")); + + // Keyframe times are per-clip: angry's JawOpen has 2 keys; switch to smile + // and JawOpen has none there (smile only keyed Smile). + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 2); + m->setActiveMorphClip(QStringLiteral("smile")); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 0); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 2); + + // Rename + delete a clip; targets survive. + const size_t posesBefore = mesh->getPoseCount(); + EXPECT_TRUE(m->renameMorphClip(QStringLiteral("angry"), QStringLiteral("mad"))); + EXPECT_TRUE(mesh->hasAnimation("mad")); + EXPECT_FALSE(mesh->hasAnimation("angry")); + EXPECT_TRUE(m->deleteMorphClip(QStringLiteral("mad"))); + EXPECT_FALSE(mesh->hasAnimation("mad")); + EXPECT_EQ(mesh->getPoseCount(), posesBefore); // shared targets untouched + sel->clear(); +} + +TEST_F(MorphAnimationManagerSceneTest, SharedTrackTwoTargetsSameTimeCoexist) { + // JawOpen and Smile are on the SAME submesh → they share one VAT_POSE track. + // Keying both at the same time in the same clip must not clobber each other, + // and clearing one must leave the other. (CodeRabbit regression.) + auto mesh = createMorphTestMesh("Morph_SharedTrack"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_SharedTrackEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + m->setActiveMorphClip(QStringLiteral("expr")); + ASSERT_TRUE(m->createMorphClip(QStringLiteral("expr"))); + + // Both targets keyed at t=0.5 on the shared track. + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.5, 0.3)); + EXPECT_TRUE(m->setMorphWeightKeyframe(QStringLiteral("Smile"), 0.5, 0.8)); + + // Each target reports its own single key at 0.5 (not the sibling's). + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 1); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 1); + EXPECT_NEAR(m->morphWeightAt(QStringLiteral("JawOpen"), 0.5), 0.3, 1e-4); + EXPECT_NEAR(m->morphWeightAt(QStringLiteral("Smile"), 0.5), 0.8, 1e-4); + + // Clearing JawOpen leaves Smile's key on the shared keyframe intact. + EXPECT_TRUE(m->clearMorphWeightKeyframe(QStringLiteral("JawOpen"), 0.5)); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("JawOpen")).size(), 0); + EXPECT_EQ(m->morphWeightKeyframeTimes(QStringLiteral("Smile")).size(), 1); + EXPECT_NEAR(m->morphWeightAt(QStringLiteral("Smile"), 0.5), 0.8, 1e-4); + sel->clear(); +} + TEST_F(MorphAnimationManagerSceneTest, NoSelectionGivesEmptyList) { auto* m = MorphAnimationManager::instance(); EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); @@ -338,6 +462,66 @@ TEST_F(MorphAnimationManagerSceneTest, RenameMorphTargetCommandRoundTrips) { EXPECT_FALSE(mesh->hasAnimation("Grin")); } +TEST_F(MorphAnimationManagerSceneTest, ReorderMorphTargetsCommandRoundTrips) { + auto mesh = createMorphTestMesh("Morph_ReorderCmd"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_ReorderCmdEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + + auto* m = MorphAnimationManager::instance(); + // Fixture order is JawOpen, Smile. + QStringList before = m->morphTargetsFor(entity); + ASSERT_EQ(before.size(), 2); + EXPECT_EQ(before[0], QStringLiteral("JawOpen")); + EXPECT_EQ(before[1], QStringLiteral("Smile")); + + QStringList after = before; + after.move(0, 1); // JawOpen -> after Smile + + ReorderMorphTargetsCommand cmd(entity, before, after); + cmd.redo(); + QStringList reordered = m->morphTargetsFor(entity); + ASSERT_EQ(reordered.size(), 2); + EXPECT_EQ(reordered[0], QStringLiteral("Smile")); + EXPECT_EQ(reordered[1], QStringLiteral("JawOpen")); + // Both animations survive the rebuild. + EXPECT_TRUE(mesh->hasAnimation("Smile")); + EXPECT_TRUE(mesh->hasAnimation("JawOpen")); + + cmd.undo(); + QStringList restored = m->morphTargetsFor(entity); + ASSERT_EQ(restored.size(), 2); + EXPECT_EQ(restored[0], QStringLiteral("JawOpen")); + EXPECT_EQ(restored[1], QStringLiteral("Smile")); +} + +TEST_F(MorphAnimationManagerSceneTest, MoveMorphTargetReordersAndClampsAtEdges) { + auto mesh = createMorphTestMesh("Morph_MoveApi"); + auto* scene = Manager::getSingleton()->getSceneMgr(); + auto* entity = scene->createEntity("Morph_MoveApiEnt", mesh->getName()); + auto* node = scene->getRootSceneNode()->createChildSceneNode(); + node->attachObject(entity); + auto* sel = SelectionSet::getSingleton(); + sel->clear(); + sel->append(node); + + auto* m = MorphAnimationManager::instance(); + // Move JawOpen (index 0) down by 1 → Smile, JawOpen. + EXPECT_TRUE(m->moveMorphTarget(QStringLiteral("JawOpen"), 1)); + QStringList o = m->morphTargetsFor(entity); + ASSERT_EQ(o.size(), 2); + EXPECT_EQ(o[0], QStringLiteral("Smile")); + EXPECT_EQ(o[1], QStringLiteral("JawOpen")); + + // Moving the last item down is a clamped no-op. + EXPECT_FALSE(m->moveMorphTarget(QStringLiteral("JawOpen"), 1)); + // Unknown name / zero delta are no-ops. + EXPECT_FALSE(m->moveMorphTarget(QStringLiteral("Nope"), -1)); + EXPECT_FALSE(m->moveMorphTarget(QStringLiteral("Smile"), 0)); + sel->clear(); +} + TEST_F(MorphAnimationManagerSceneTest, RenameRejectsCollisionAndIdempotentName) { auto mesh = createMorphTestMesh("Morph_RenameReject"); auto* scene = Manager::getSingleton()->getSceneMgr(); diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 108d26840..b8ef182f0 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -13,7 +13,10 @@ #include "AnimationMerger.h" #include "CurveEditModel.h" #include "EditModeController.h" +#include "MorphAnimationManager.h" #include "SentryReporter.h" + +#include #include "MeshImporterExporter.h" #include "UndoManager.h" #include "commands/ApplyMaterialCommand.h" @@ -1045,16 +1048,30 @@ QVariantList PropertiesPanelController::animationData() const entityGroup["showSkeleton"] = mAnimationWidget ? mAnimationWidget->isSkeletonDebugActive(ent) : false; entityGroup["showWeights"] = mAnimationWidget ? mAnimationWidget->isBoneWeightsShown(ent) : false; + // Morph targets are each backed by a same-named Ogre::Animation, so + // getAllAnimationStates() lists every blend shape as a "clip". They're + // authored/edited in the Edit-Mode "Vertex Morph Animation" group, not + // here — filter them out so Animation Mode shows only real animation + // clips (skeletal + Alembic vertex caches), by NAME. A vertex-cache + // clip is NOT a pose name, so it survives the filter. + QSet morphNames; + for (const QString& n : MorphAnimationManager::instance()->morphTargetsFor(ent)) + morphNames.insert(n); + QVariantList anims; for (const auto& [key, state] : states->getAnimationStates()) { + const QString name = QString::fromStdString(key); + if (morphNames.contains(name)) continue; // blend shape, not a clip QVariantMap anim; - anim["name"] = QString::fromStdString(key); + anim["name"] = name; anim["enabled"] = state->getEnabled(); anim["loop"] = state->getLoop(); anim["length"] = state->getLength(); anims.append(anim); } + // Skip an entity that only had morph targets — its group would be empty. + if (anims.isEmpty()) continue; entityGroup["animations"] = anims; result.append(entityGroup); } @@ -1139,6 +1156,21 @@ bool PropertiesPanelController::renameAnimation(const QString& entityName, const for (Ogre::Entity* ent : entities) { if (QString::fromStdString(ent->getName()) != entityName) continue; + + // Morph (weight) clips are mesh-level VAT_POSE animations, not skeletal + // — the skeleton rename path below can't handle them (and would crash on + // a null skeleton). Detect it on THIS named entity's mesh (not the + // first-selected one — respects the entityName arg under multi-select) + // and delegate to the manager's mesh-aware rename. + Ogre::MeshPtr mesh = ent->getMesh(); + if (mesh && mesh->hasAnimation(oldName.toStdString())) { + bool isPoseName = false; + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == oldName.toStdString()) { isPoseName = true; break; } + if (!isPoseName) // a weight clip, not a per-target shape clip + return MorphAnimationManager::instance()->renameMorphClip(oldName, newName); + } + if (Manager::getSingleton()->hasAnimationName(ent, newName)) return false; // Disable skeleton debug/weights and stop playback before rename diff --git a/src/SkeletonTransform.cpp b/src/SkeletonTransform.cpp index 1c5d2c496..7da81ec06 100755 --- a/src/SkeletonTransform.cpp +++ b/src/SkeletonTransform.cpp @@ -116,7 +116,13 @@ bool SkeletonTransform::renameAnimation(Ogre::Entity *_ent, const QString &_oldN if(_newName.isEmpty()) return false; - if(!_ent || !_ent->getSkeleton()->hasAnimation(_oldName.toStdString().data())) + // Guard the skeleton null case: a morph-only / vertex-cache mesh has no + // skeleton, and its clips live on the mesh (VAT_POSE), not here. Callers + // must route those to the mesh-animation rename path — bail (don't crash) + // rather than dereference a null skeleton. + if(!_ent || !_ent->hasSkeleton()) + return false; + if(!_ent->getSkeleton()->hasAnimation(_oldName.toStdString().data())) return false; auto *sk = _ent->getSkeleton(); diff --git a/src/VertexAnimationManager.cpp b/src/VertexAnimationManager.cpp new file mode 100644 index 000000000..79d6a3aeb --- /dev/null +++ b/src/VertexAnimationManager.cpp @@ -0,0 +1,234 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#include "VertexAnimationManager.h" + +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Singletons run on the main thread (CLAUDE.md). Assert at lifecycle entry. +inline void assertMainThread() +{ + Q_ASSERT(QCoreApplication::instance()); + Q_ASSERT(QThread::currentThread() == QCoreApplication::instance()->thread()); +} + +// The issue's cutoff: caches below this frame count store as blend-able poses. +constexpr int kPoseStorageMaxFrames = 32; + +// Read submesh-0 (or shared) bind positions into a flat xyz array. Returns the +// vertex count (0 on failure). Mirrors gatherGeometry's position walk but for a +// single target-geometry the vertex-anim poses are built against. +int readBindPositions(Ogre::Mesh* mesh, std::vector& out) +{ + out.clear(); + if (!mesh || mesh->getNumSubMeshes() == 0) return 0; + Ogre::SubMesh* sub = mesh->getSubMesh(0); + Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData + : sub->vertexData; + if (!vd) return 0; + const Ogre::VertexElement* posElem = + vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!posElem) return 0; + auto vbuf = vd->vertexBufferBinding->getBuffer(posElem->getSource()); + if (!vbuf) return 0; + + const size_t count = vd->vertexCount; + out.resize(count * 3); + auto* base = static_cast(vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t v = 0; v < count; ++v) { + float* p = nullptr; + posElem->baseVertexPointerToElement(base + v * vbuf->getVertexSize(), &p); + out[v * 3 + 0] = p[0]; + out[v * 3 + 1] = p[1]; + out[v * 3 + 2] = p[2]; + } + vbuf->unlock(); + return static_cast(count); +} + +} // namespace + +VertexAnimationManager* VertexAnimationManager::s_instance = nullptr; + +VertexAnimationManager* VertexAnimationManager::instance() +{ + assertMainThread(); + if (!s_instance) s_instance = new VertexAnimationManager(); + return s_instance; +} + +VertexAnimationManager* VertexAnimationManager::qmlInstance(QQmlEngine*, QJSEngine*) +{ + assertMainThread(); + auto* inst = instance(); + // Process-wide singleton shared across every QQuickWidget's QQmlEngine. + // Pin CppOwnership so no engine's GC can delete the shared instance and + // leave a dangling pointer for the others — matching every sibling + // singleton's qmlInstance (see MorphAnimationManager for the full note). + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void VertexAnimationManager::kill() +{ + assertMainThread(); + if (!s_instance) return; + delete s_instance; + s_instance = nullptr; +} + +VertexAnimationManager::VertexAnimationManager(QObject* parent) : QObject(parent) +{ + if (auto* sel = SelectionSet::getSingleton()) { + connect(sel, &SelectionSet::selectionChanged, + this, &VertexAnimationManager::vertexAnimationsChanged); + } +} + +VertexAnimationManager::~VertexAnimationManager() = default; + +VertexAnimationManager::Storage VertexAnimationManager::sampleHeuristic(int frameCount) +{ + return frameCount < kPoseStorageMaxFrames ? Storage::Poses : Storage::Stream; +} + +bool VertexAnimationManager::buildClipFromFrames(Ogre::Mesh* mesh, + const QString& clipName, + const FrameSet& frames) +{ + if (!mesh || clipName.isEmpty() || !frames.ok()) + return false; + + std::vector bind; + const int vcount = readBindPositions(mesh, bind); + if (vcount <= 0 || vcount != frames.vertexCount) + return false; + + const std::string animName = clipName.toStdString(); + if (mesh->hasAnimation(animName)) + mesh->removeAnimation(animName); + + // Rebuilding the same clip must also drop the dense per-frame poses it + // created last time ("/frameN"). Ogre poses are mesh-level and are + // still walked by the dope-sheet / export paths, so without this a + // re-import appends stale poses (and shifts every pose index). removePose + // is index-based, so collect matching indices and erase from the back to + // keep the remaining indices stable. (Same pattern as MorphCommands.) + { + const std::string prefix = animName + "/frame"; + const auto& poseList = mesh->getPoseList(); + std::vector drop; + for (unsigned short pi = 0; pi < poseList.size(); ++pi) { + if (poseList[pi] && poseList[pi]->getName().compare(0, prefix.size(), prefix) == 0) + drop.push_back(pi); + } + for (auto it = drop.rbegin(); it != drop.rend(); ++it) + mesh->removePose(*it); + } + + // VAT_POSE targets submesh handle 1 (submesh 0); 0 is shared geometry. + Ogre::SubMesh* sub = mesh->getSubMesh(0); + const unsigned short target = sub->useSharedVertices ? 0 : 1; + + const float length = frames.frames.back().time - frames.frames.front().time; + Ogre::Animation* anim = + mesh->createAnimation(animName, length > 0.0f ? length : 0.0f); + if (!anim) return false; + Ogre::VertexAnimationTrack* track = + anim->createVertexTrack(target, Ogre::VAT_POSE); + if (!track) { mesh->removeAnimation(animName); return false; } + + const float t0 = frames.frames.front().time; + // One Pose per frame (delta vs bind) + one keyframe at that frame's time + // referencing it at full weight. VAT_POSE interpolates the pose references + // between consecutive keyframes, so scrubbing the timeline blends frames. + for (size_t f = 0; f < frames.frames.size(); ++f) { + const FrameData& fd = frames.frames[f]; + if (static_cast(fd.positions.size()) != vcount * 3) { + mesh->removeAnimation(animName); + return false; + } + const unsigned short poseIndex = + static_cast(mesh->getPoseCount()); + Ogre::Pose* pose = mesh->createPose( + target, clipName.toStdString() + "/frame" + std::to_string(f)); + for (int v = 0; v < vcount; ++v) { + const Ogre::Vector3 delta(fd.positions[v * 3 + 0] - bind[v * 3 + 0], + fd.positions[v * 3 + 1] - bind[v * 3 + 1], + fd.positions[v * 3 + 2] - bind[v * 3 + 2]); + // Store every vertex (dense by nature); a zero delta is harmless. + pose->addVertex(static_cast(v), delta); + } + auto* kf = track->createVertexPoseKeyFrame(fd.time - t0); + kf->addPoseReference(poseIndex, 1.0f); + } + + mesh->load(); + SentryReporter::addBreadcrumb( + QStringLiteral("scene.anim.vertex_anim"), + QStringLiteral("built VAT_POSE clip '%1' — %2 frames, %3 verts") + .arg(clipName).arg(frames.frames.size()).arg(vcount)); + return true; +} + +bool VertexAnimationManager::hasVertexAnimation(Ogre::Entity* entity) const +{ + return !vertexClipsFor(entity).isEmpty(); +} + +QStringList VertexAnimationManager::vertexClipsFor(Ogre::Entity* entity) const +{ + QStringList out; + if (!entity || !entity->getMesh()) return out; + Ogre::MeshPtr mesh = entity->getMesh(); + for (unsigned short i = 0; i < mesh->getNumAnimations(); ++i) { + Ogre::Animation* a = mesh->getAnimation(i); + if (!a) continue; + // A vertex-anim clip is a mesh Animation carrying a vertex track. (This + // also matches morph clips; the "Mesh" dope-sheet row treats them the + // same — a single scrubbable vertex row.) + bool hasVertexTrack = false; + for (const auto& kv : a->_getVertexTrackList()) { + if (kv.second) { hasVertexTrack = true; break; } + } + if (hasVertexTrack) + out << QString::fromStdString(a->getName()); + } + return out; +} + +QStringList VertexAnimationManager::vertexClipsForSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return {}; + const auto entities = sel->getResolvedEntities(); + return entities.isEmpty() ? QStringList{} : vertexClipsFor(entities.first()); +} + +bool VertexAnimationManager::selectionHasVertexAnimation() const +{ + return !vertexClipsForSelection().isEmpty(); +} diff --git a/src/VertexAnimationManager.h b/src/VertexAnimationManager.h new file mode 100644 index 000000000..16f295f4b --- /dev/null +++ b/src/VertexAnimationManager.h @@ -0,0 +1,131 @@ +/* +----------------------------------------------------------------------------------- +A QtMeshEditor file + +Copyright (c) Fernando Tonon (https://github.com/fernandotonon) + +The MIT License +----------------------------------------------------------------------------------- +*/ + +#ifndef VERTEXANIMATIONMANAGER_H +#define VERTEXANIMATIONMANAGER_H + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace Ogre { class Entity; class Mesh; } + +/** + * @brief Full-mesh (per-vertex) animation — Anim epic Slice B (#519). + * + * Where MorphAnimationManager (Slice A) drives a handful of NAMED blend-shape + * weights, this manager owns clips where EVERY vertex moves per frame with no + * skeleton — cloth, sims, fluid bakes, destruction, and Alembic caches from + * Houdini / Blender. + * + * Both use Ogre's VertexAnimationTrack; the difference is intent + density: a + * morph clip has a few poses the user blends, a vertex-anim clip has one dense + * per-frame shape. We reuse Ogre's VAT_POSE path (one Pose per frame, keyed at + * full weight at that frame's time) which the viewport/timeline already play — + * so the timeline scrubber, loop, and dope sheet work with no new playback code. + * + * SUB-SLICE LAYERING (see issue #519 / the plan): + * - B1 (this file): the manager + the CPU-side "sample buffer -> Ogre + * VAT_POSE Animation on the mesh" builder + playback/dope-sheet wiring + + * headless tests, all reachable WITHOUT the Alembic dependency (a synthetic + * buffer is enough to exercise + test the whole path). + * - B2: the real Alembic reader (behind -DENABLE_ALEMBIC) fills a sample + * buffer and hands it to `buildClipFromFrames`. + * - B3: disk-streaming for caches too large to hold resident, CLI/MCP, and + * .abc -> FBX vertex-cache convert. + * + * The pure-data core (`FrameData`, `buildClipFromFrames`, `sampleHeuristic`) + * takes flat float arrays and an Ogre::Mesh, so it is unit-testable under + * headless CI with a synthetic cube-wobble buffer — no Alembic, no GL. + */ +class VertexAnimationManager : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static VertexAnimationManager* instance(); + static VertexAnimationManager* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// How a vertex-anim clip is stored on the mesh. The issue's heuristic: + /// small caches become blend-able poses (cheap, GPU-friendly); large ones + /// would stream (B3). `sampleHeuristic()` picks per the frame count. + enum class Storage { Poses, Stream }; + + /// One decoded frame's vertex data. Positions are flat xyz triples in the + /// mesh's own space, `vertexCount` long. Optional per-frame normals (same + /// layout) improve shading on deforming meshes; empty = recompute/none. + struct FrameData { + float time = 0.0f; ///< seconds from clip start + std::vector positions; ///< 3 * vertexCount + std::vector normals; ///< 0 or 3 * vertexCount + }; + + /// Decoded, source-agnostic vertex-animation clip (what an Alembic reader, + /// or the synthetic generator in tests, produces). Pure data. + struct FrameSet { + int vertexCount = 0; + int fps = 30; + std::vector frames; ///< time-ordered, >= 2 to animate + std::array aabb{ {0,0,0,0,0,0} }; ///< minXYZ, maxXYZ over all frames + bool ok() const { return vertexCount > 0 && frames.size() >= 2; } + }; + + /// Pure-data heuristic (issue #519): < 32 keyframes -> pose blending, + /// otherwise a streamed/flat path. Public + static so it is unit-tested. + static Storage sampleHeuristic(int frameCount); + + /// Build an Ogre VAT_POSE Animation named `clipName` on `mesh` from a + /// decoded FrameSet: one Pose per frame (delta vs. the mesh's bind + /// positions) with a VertexAnimationTrack keyed to full weight at each + /// frame's time. The mesh must already have `frames.vertexCount` vertices + /// in submesh 0 / shared geometry. Returns false (and adds nothing) on + /// mismatch. Ogre-only, no GL required — safe under headless CI. + /// + /// This is the Storage::Poses path. Storage::Stream (large caches) is B3; + /// until then a too-large FrameSet still builds poses (correct, just + /// heavier) so nothing silently fails. + static bool buildClipFromFrames(Ogre::Mesh* mesh, + const QString& clipName, + const FrameSet& frames); + + /// True when `entity` has at least one VAT_POSE (vertex-anim or morph) + /// animation. Used by the UI to show the "Mesh" dope-sheet row. + bool hasVertexAnimation(Ogre::Entity* entity) const; + + /// Names of the vertex-animation clips on `entity` (Ogre animations that + /// carry a vertex track). Empty when none / null. + QStringList vertexClipsFor(Ogre::Entity* entity) const; + + /// QML-friendly variants resolving the entity from SelectionSet. + Q_INVOKABLE QStringList vertexClipsForSelection() const; + Q_INVOKABLE bool selectionHasVertexAnimation() const; + +signals: + /// Emitted when the vertex-anim clip list visible to the UI could have + /// changed (import, selection moved, scene reloaded). + void vertexAnimationsChanged(); + +private: + explicit VertexAnimationManager(QObject* parent = nullptr); + ~VertexAnimationManager() override; + + static VertexAnimationManager* s_instance; +}; + +#endif // VERTEXANIMATIONMANAGER_H diff --git a/src/VertexAnimationManager_test.cpp b/src/VertexAnimationManager_test.cpp new file mode 100644 index 000000000..eddcb9297 --- /dev/null +++ b/src/VertexAnimationManager_test.cpp @@ -0,0 +1,195 @@ +#include + +#include "Manager.h" +#include "SelectionSet.h" +#include "TestHelpers.h" +#include "VertexAnimationManager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +// A minimal static mesh (one triangle, 3 verts) the vertex-anim clip is built +// against — same shape MeshGenBuilder/MeshProcessor produce (non-shared +// submesh, POSITION+NORMAL). Vertex-anim poses target submesh handle 1. +Ogre::MeshPtr createStaticMesh(const std::string& name, int nverts = 3) +{ + auto mesh = Ogre::MeshManager::getSingleton().createManual( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* sub = mesh->createSubMesh(); + sub->useSharedVertices = false; + sub->vertexData = new Ogre::VertexData(); + auto* decl = sub->vertexData->vertexDeclaration; + size_t off = 0; + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_POSITION); + off += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3); + decl->addElement(0, off, Ogre::VET_FLOAT3, Ogre::VES_NORMAL); + + auto vbuf = Ogre::HardwareBufferManager::getSingleton().createVertexBuffer( + decl->getVertexSize(0), nverts, Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + std::vector verts(static_cast(nverts) * 6, 0.0f); + for (int v = 0; v < nverts; ++v) { + verts[v * 6 + 0] = static_cast(v); // x = index — distinct bind + verts[v * 6 + 5] = 1.0f; // normal +Z + } + vbuf->writeData(0, verts.size() * sizeof(float), verts.data()); + sub->vertexData->vertexBufferBinding->setBinding(0, vbuf); + sub->vertexData->vertexCount = static_cast(nverts); + + auto ibuf = Ogre::HardwareBufferManager::getSingleton().createIndexBuffer( + Ogre::HardwareIndexBuffer::IT_16BIT, 3, + Ogre::HardwareBuffer::HBU_STATIC_WRITE_ONLY); + uint16_t idx[] = {0, 1, 2}; + ibuf->writeData(0, sizeof(idx), idx); + sub->indexData->indexBuffer = ibuf; + sub->indexData->indexCount = 3; + + mesh->_setBounds(Ogre::AxisAlignedBox(-1, -1, -1, 4, 4, 4)); + mesh->_setBoundingSphereRadius(4.0f); + mesh->load(); + return mesh; +} + +// A synthetic vertex-animation buffer: `frameCount` frames of `nverts` vertices +// wobbling in Y over time — the stand-in for a decoded Alembic cache. Bind +// positions match createStaticMesh (x=index), so frame deltas are pure Y. +VertexAnimationManager::FrameSet makeWobble(int nverts, int frameCount, int fps = 30) +{ + VertexAnimationManager::FrameSet fs; + fs.vertexCount = nverts; + fs.fps = fps; + for (int f = 0; f < frameCount; ++f) { + VertexAnimationManager::FrameData fd; + fd.time = static_cast(f) / static_cast(fps); + fd.positions.resize(static_cast(nverts) * 3); + for (int v = 0; v < nverts; ++v) { + fd.positions[v * 3 + 0] = static_cast(v); + fd.positions[v * 3 + 1] = + 0.5f * std::sin(static_cast(f) * 0.5f + static_cast(v)); + fd.positions[v * 3 + 2] = 0.0f; + } + fs.frames.push_back(std::move(fd)); + } + return fs; +} + +} // namespace + +// ============================================================================= +// Pure-data (no Ogre) — heuristic + FrameSet validity +// ============================================================================= + +TEST(VertexAnimationManagerStandalone, InstanceIsSingleton) { + EXPECT_EQ(VertexAnimationManager::instance(), VertexAnimationManager::instance()); +} + +TEST(VertexAnimationManagerStandalone, HeuristicSplitsAtThreshold) { + using S = VertexAnimationManager::Storage; + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(2), S::Poses); + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(31), S::Poses); + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(32), S::Stream); + EXPECT_EQ(VertexAnimationManager::sampleHeuristic(1000), S::Stream); +} + +TEST(VertexAnimationManagerStandalone, FrameSetOkRequiresTwoFrames) { + EXPECT_FALSE(VertexAnimationManager::FrameSet{}.ok()); + auto one = makeWobble(3, 1); + EXPECT_FALSE(one.ok()); + auto two = makeWobble(3, 2); + EXPECT_TRUE(two.ok()); +} + +// ============================================================================= +// Ogre-backed — clip construction + enumeration (headless-safe) +// ============================================================================= + +class VertexAnimationManagerTest : public ::testing::Test { +protected: + void SetUp() override { + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + } + void TearDown() override { + auto& mm = Ogre::MeshManager::getSingleton(); + for (const char* n : {"vam_build", "vam_enum", "vam_mismatch"}) + if (mm.resourceExists(n)) mm.remove(n); + } +}; + +TEST_F(VertexAnimationManagerTest, BuildClipFromFramesCreatesPosesAndTrack) { + auto mesh = createStaticMesh("vam_build", 3); + auto fs = makeWobble(3, 8); + + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames(mesh.get(), "wobble", fs)); + + // One Animation named "wobble" with a vertex track and one pose per frame. + ASSERT_TRUE(mesh->hasAnimation("wobble")); + Ogre::Animation* a = mesh->getAnimation("wobble"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(mesh->getPoseCount(), 8u); // one pose per frame + Ogre::VertexAnimationTrack* track = a->getVertexTrack(1); // submesh handle 1 + ASSERT_NE(track, nullptr); + EXPECT_EQ(track->getAnimationType(), Ogre::VAT_POSE); + EXPECT_EQ(track->getNumKeyFrames(), 8u); // one keyframe per frame + // Clip length spans the frame times (8 frames @30fps → 7/30 s). + EXPECT_NEAR(a->getLength(), 7.0f / 30.0f, 1e-4f); +} + +// Rebuilding the same clip must not leak the previous run's per-frame poses +// ("/frameN"). Ogre poses are mesh-level; a leak would accumulate and +// shift pose indices (regression from the B3 review). +TEST_F(VertexAnimationManagerTest, RebuildDoesNotLeakPoses) { + auto mesh = createStaticMesh("vam_rebuild", 3); + + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "wobble", makeWobble(3, 8))); + EXPECT_EQ(mesh->getPoseCount(), 8u); + + // Rebuild with a DIFFERENT frame count — pose count must reflect only the + // new build, not 8 + 5. + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "wobble", makeWobble(3, 5))); + EXPECT_EQ(mesh->getPoseCount(), 5u); + ASSERT_TRUE(mesh->hasAnimation("wobble")); + EXPECT_EQ(mesh->getAnimation("wobble")->getVertexTrack(1)->getNumKeyFrames(), 5u); + + // A second, differently-named clip must coexist (only same-named frames + // are dropped). + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "other", makeWobble(3, 4))); + EXPECT_EQ(mesh->getPoseCount(), 9u); // 5 (wobble) + 4 (other) +} + +TEST_F(VertexAnimationManagerTest, BuildClipRejectsVertexCountMismatch) { + auto mesh = createStaticMesh("vam_mismatch", 3); + auto fs = makeWobble(5, 4); // 5 verts vs the mesh's 3 + EXPECT_FALSE(VertexAnimationManager::buildClipFromFrames(mesh.get(), "bad", fs)); + EXPECT_FALSE(mesh->hasAnimation("bad")); +} + +TEST_F(VertexAnimationManagerTest, EnumeratesVertexClipsOnEntity) { + auto mesh = createStaticMesh("vam_enum", 3); + ASSERT_TRUE(VertexAnimationManager::buildClipFromFrames( + mesh.get(), "sim", makeWobble(3, 4))); + + Ogre::SceneManager* sm = Manager::getSingleton()->getSceneMgr(); + Ogre::Entity* ent = sm->createEntity("vam_enum_ent", "vam_enum"); + + auto* vam = VertexAnimationManager::instance(); + EXPECT_TRUE(vam->hasVertexAnimation(ent)); + const QStringList clips = vam->vertexClipsFor(ent); + ASSERT_EQ(clips.size(), 1); + EXPECT_EQ(clips.first(), QStringLiteral("sim")); + + sm->destroyEntity(ent); +} diff --git a/src/commands/MorphCommands.cpp b/src/commands/MorphCommands.cpp index b57182ba1..4ab6853ca 100644 --- a/src/commands/MorphCommands.cpp +++ b/src/commands/MorphCommands.cpp @@ -10,6 +10,7 @@ The MIT License #include "MorphCommands.h" +#include "../PropertiesPanelController.h" #include "../SentryReporter.h" #include @@ -51,6 +52,24 @@ void removePosesByName(Ogre::Mesh* mesh, const QString& name, Ogre::Entity* enti if (!mesh) return; const std::string sn = name.toStdString(); + // STOP PLAYBACK first. The render frame loop (MainWindow:: + // frameRenderingQueued) iterates the entity's AnimationStateSet and reads + // each VAT_POSE track's pose references every frame. Removing a pose / + // animation / state here while a clip is playing frees data the loop is + // mid-read of → crash (reproduced: play a morph/vertex clip, delete a + // target). deleteAnimation/renameAnimation already stop playback before + // mutating; do the same at this shared mutation point so every entry + // (delete + rename redo/undo) is safe. Also disable the state before + // removing it so no dangling enabled state survives the refresh. + if (auto* ppc = PropertiesPanelController::instance()) + ppc->setPlaying(false); + if (entity) { + if (auto* states = entity->getAllAnimationStates()) { + if (states->hasAnimationState(sn)) + states->getAnimationState(sn)->setEnabled(false); + } + } + // removePose(name) only removes the *first* pose with that name — // when an importer pose has the same name across multiple submeshes // we'd leak the rest. Walk + remove by index from the back so the @@ -122,8 +141,103 @@ void buildPosesFromSlices(Ogre::Mesh* mesh, kf->addPoseReference(poseIndices[i], 1.0f); } - if (entity) + if (entity) { + // Adding poses / a VAT_POSE animation to an already-loaded mesh means + // the entity's pose (software + hardware) vertex-animation buffers were + // never allocated — the importer sets poses up BEFORE the entity is + // built, but here we add them to a LIVE entity. Re-initialise so Ogre + // rebuilds those buffers; without it the render loop applies a pose + // animation against null buffers and crashes (skinned meshes especially, + // where skeletal + pose animation combine). Mirrors the AutoRig path, + // which likewise re-initialises after mutating a live entity's mesh. + entity->_initialise(true); entity->refreshAvailableAnimationState(); + } +} + +// ─── Weight-clip preserve/restore across a pose reorder ────────────── +// Morph weight clips (mesh Animations that aren't per-target shape clips) +// reference poses by INDEX. A reorder rebuilds poses in a new order, so those +// indices become stale. Snapshot each weight clip's keyframes by pose NAME +// before the reorder, then rebuild them against the new pose indices after. + +// name != a pose name → it's a weight clip, not a shape clip. +bool isWeightClip(Ogre::Mesh* mesh, const std::string& animName) +{ + for (const Ogre::Pose* p : mesh->getPoseList()) + if (p && p->getName() == animName) return false; + return true; +} + +struct WeightClipSnapshot { + std::string name; + float length = 0.0f; + // time -> (poseName -> weight) + std::vector>> keys; +}; + +std::vector snapshotWeightClips(Ogre::Mesh* mesh) +{ + std::vector out; + for (unsigned short a = 0; a < mesh->getNumAnimations(); ++a) { + Ogre::Animation* anim = mesh->getAnimation(a); + if (!anim || !isWeightClip(mesh, anim->getName())) continue; + WeightClipSnapshot snap; + snap.name = anim->getName(); + snap.length = anim->getLength(); + // Merge all VAT_POSE tracks' keyframes keyed by time; resolve each pose + // reference's index -> name against the CURRENT pose list. + std::map> byTime; + for (const auto& [handle, track] : anim->_getVertexTrackList()) { + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + for (unsigned short k = 0; k < track->getNumKeyFrames(); ++k) { + auto* kf = static_cast(track->getKeyFrame(k)); + for (const auto& ref : kf->getPoseReferences()) { + if (ref.poseIndex >= mesh->getPoseList().size()) continue; + const Ogre::Pose* p = mesh->getPoseList()[ref.poseIndex]; + if (p) byTime[kf->getTime()][p->getName()] = ref.influence; + } + } + } + for (auto& [t, m] : byTime) snap.keys.push_back({t, std::move(m)}); + out.push_back(std::move(snap)); + } + return out; +} + +// Rebuild the snapshotted weight clips against the (reordered) pose list, +// resolving pose names to their NEW indices + submesh handles. +void rebuildWeightClips(Ogre::Mesh* mesh, const std::vector& snaps, + Ogre::Entity* entity) +{ + auto poseIndex = [&](const std::string& name) -> int { + const auto& pl = mesh->getPoseList(); + for (unsigned short i = 0; i < pl.size(); ++i) + if (pl[i] && pl[i]->getName() == name) return i; + return -1; + }; + for (const auto& snap : snaps) { + if (mesh->hasAnimation(snap.name)) mesh->removeAnimation(snap.name); + Ogre::Animation* anim = mesh->createAnimation(snap.name, snap.length); + for (const auto& [t, weights] : snap.keys) { + for (const auto& [poseName, w] : weights) { + const int pi = poseIndex(poseName); + if (pi < 0) continue; + const unsigned short handle = mesh->getPoseList()[pi]->getTarget(); + Ogre::VertexAnimationTrack* track = anim->hasVertexTrack(handle) + ? anim->getVertexTrack(handle) + : anim->createVertexTrack(handle, Ogre::VAT_POSE); + Ogre::VertexPoseKeyFrame* kf = nullptr; + for (unsigned short ki = 0; ki < track->getNumKeyFrames(); ++ki) { + auto* e = static_cast(track->getKeyFrame(ki)); + if (std::abs(e->getTime() - t) < 1e-4f) { kf = e; break; } + } + if (!kf) kf = track->createVertexPoseKeyFrame(t); + kf->updatePoseReference(static_cast(pi), w); + } + } + } + if (entity) entity->refreshAvailableAnimationState(); } } // namespace @@ -232,3 +346,67 @@ void RenameMorphTargetCommand::undo() removePosesByName(mesh.get(), mNewName, mEntity); buildPosesFromSlices(mesh.get(), mOldName, mSnapshot, mEntity); } + +// ──────────────── ReorderMorphTargetsCommand ──────────────────────── + +ReorderMorphTargetsCommand::ReorderMorphTargetsCommand(Ogre::Entity* entity, + const QStringList& oldOrder, + const QStringList& newOrder, + QUndoCommand* parent) + : QUndoCommand(parent), + mEntity(entity), + mOldOrder(oldOrder), + mNewOrder(newOrder) +{ + setText(QStringLiteral("Reorder morph targets")); + if (mEntity) { + if (Ogre::MeshPtr mesh = mEntity->getMesh()) { + // Snapshot every target's slices once — both undo and redo rebuild + // from these, so the offsets survive the intermediate teardown. + for (const QString& n : mOldOrder) + mSnapshot[n] = snapshotByName(mesh.get(), n.toStdString()); + } + } +} + +void ReorderMorphTargetsCommand::applyOrder(const QStringList& order) +{ + if (!mEntity) return; + Ogre::MeshPtr mesh = mEntity->getMesh(); + if (!mesh) return; + + // Pose indices are positional and VAT_POSE keyframes reference them by + // index, so the only safe reorder is a full teardown + rebuild in the + // desired name-order. removePosesByName drops each target's poses + + // Animation; buildPosesFromSlices recreates them (and their keyframe + // references) fresh, in call order → the new display order. + // + // Weight clips (smile/angry/…) reference poses by index too but AREN'T + // rebuilt by the shape teardown below (their names differ from pose names), + // so their keyframes would point at the wrong target after the reorder. + // Snapshot them by pose NAME first, then rebuild against the new indices. + const std::vector weightClips = snapshotWeightClips(mesh.get()); + + for (const QString& n : order) + removePosesByName(mesh.get(), n, mEntity); + for (const QString& n : order) { + auto it = mSnapshot.find(n); + if (it != mSnapshot.end()) + buildPosesFromSlices(mesh.get(), n, it->second, mEntity); + } + + // Re-key the weight clips to the reordered poses (by name → new index). + rebuildWeightClips(mesh.get(), weightClips, mEntity); +} + +void ReorderMorphTargetsCommand::redo() +{ + applyOrder(mNewOrder); + SentryReporter::addBreadcrumb("scene.anim.morph", + QStringLiteral("reorder targets")); +} + +void ReorderMorphTargetsCommand::undo() +{ + applyOrder(mOldOrder); +} diff --git a/src/commands/MorphCommands.h b/src/commands/MorphCommands.h index 381b18478..b7ca7d213 100644 --- a/src/commands/MorphCommands.h +++ b/src/commands/MorphCommands.h @@ -13,6 +13,7 @@ The MIT License #include #include +#include #include @@ -94,4 +95,29 @@ class RenameMorphTargetCommand : public QUndoCommand std::vector mSnapshot; }; +// ReorderMorphTargetsCommand: change the display order of morph targets. +// The order is defined by the sequence of unique names in the mesh's pose +// list; VAT_POSE keyframes reference poses by INDEX, so a reorder rebuilds +// every target's poses + driving Animation in the new order (recreating the +// keyframe references correctly). Undo restores the previous order. Captures +// both orders + a per-name slice snapshot at construction. +class ReorderMorphTargetsCommand : public QUndoCommand +{ +public: + ReorderMorphTargetsCommand(Ogre::Entity* entity, + const QStringList& oldOrder, + const QStringList& newOrder, + QUndoCommand* parent = nullptr); + void undo() override; + void redo() override; + +private: + void applyOrder(const QStringList& order); + Ogre::Entity* mEntity = nullptr; + QStringList mOldOrder; + QStringList mNewOrder; + // name -> its pose slices, captured up-front so rebuild is exact. + std::map> mSnapshot; +}; + #endif // MORPH_COMMANDS_H diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index de6e06121..a16d55e95 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -136,6 +136,7 @@ #include "IsometricSpritesController.h" #include "ImageTo3D/MeshGenController.h" #include "MorphAnimationManager.h" +#include "VertexAnimationManager.h" #include "EditorModeController.h" #include "QtMeshCloudClient.h" #include @@ -935,6 +936,10 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return MorphAnimationManager::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType("PropertiesPanel", 1, 0, "VertexAnimationManager", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return VertexAnimationManager::qmlInstance(engine, nullptr); + }); // Same image provider the detached editor window uses — serves the // live paint buffer as a QImage view (no PNG encode, no base64). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9c9b7a3c8..f92a8904e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -146,6 +146,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/NodeAnimCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/PoseLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/PoseLibraryCommands.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/VertexAnimationManager.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AlembicImporter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/UVEditCommand.cpp @@ -528,6 +530,9 @@ if(BUILD_TESTS) if(ENABLE_ONNX) list(APPEND TEST_SUPPORT_LIBRARIES qtmesh_onnx) endif() + if(ENABLE_ALEMBIC) + list(APPEND TEST_SUPPORT_LIBRARIES qtmesh_alembic) + endif() add_library(qtmesh_test_common STATIC ${TEST_SRC_FILES} ${TEST_HEADER_FILES}) set_target_properties(qtmesh_test_common PROPERTIES