diff --git a/CLAUDE.md b/CLAUDE.md index cf38f7ed..78d6ab1b 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 @@ -208,6 +210,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 d0d80c0b..31a8733d 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,6 +274,25 @@ 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 for now: the Alembic + Imath build is +# non-trivial across the three target platforms, so Slice B lands in sub-slices +# — B1 (VertexAnimationManager + Ogre VAT_POSE playback + tests) needs NO +# Alembic and builds/tests everywhere; B2 flips this ON for desktop and wires +# 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 00000000..d0382833 --- /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/PropertiesPanel.qml b/qml/PropertiesPanel.qml index c2b51e74..b4578eb5 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -489,6 +489,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: false + + Component.onCompleted: content = vertexMorphComponent + } + // ---- AI: Image → 3D (epic #764, Object mode) ---- CollapsibleSection { title: "AI: Image → 3D" @@ -676,22 +691,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 @@ -7057,339 +7083,526 @@ Rectangle { } } - // ---- Morph Targets / Blend Shapes (slice A2) ---- - // Per-pose weight sliders, sourced from MorphAnimationManager. - // Lives at the bottom of the Animations section, outside the - // per-entity repeater above — morph data is read from the - // SelectionSet's first entity to keep the surface focused. - // Authoring (add/rename/delete) lands in A3. - Rectangle { - width: parent.width - 16 - visible: morphCol.targetCount > 0 - height: morphCol.implicitHeight + 12 - color: PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - radius: 3 + } + } - Column { - id: morphCol - anchors.fill: parent - anchors.margins: 6 - spacing: 4 + // ---- 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 + // Show when the mesh already has targets, OR when an entity is + // selected so the FIRST target can be authored. Without the + // second clause the panel (and its "+ Add…" button) was gated on + // targetCount > 0 — a chicken-and-egg that made it impossible to + // create the first morph target from the UI. The Add button + // stays disabled outside Edit Mode (with a tooltip) so the empty + // panel just advertises the feature + the entry path. + visible: morphCol.targetCount > 0 + || PropertiesPanelController.hasEntitySelection + height: morphCol.implicitHeight + 12 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 - // 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 + Column { + id: morphCol + anchors.fill: parent + anchors.margins: 6 + spacing: 4 - Connections { - target: MorphAnimationManager - function onMorphTargetsChanged() { - morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] - morphCol.weightTick = morphCol.weightTick + 1 + // 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 + + Connections { + target: MorphAnimationManager + function onMorphTargetsChanged() { + morphCol.targets = MorphAnimationManager.morphTargetsForSelection() || [] + morphCol.weightTick = morphCol.weightTick + 1 + } + function onMorphWeightChanged(entity, name, weight) { + morphCol.weightTick = morphCol.weightTick + 1 + } + } + + // 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: "Morph Targets (" + 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 } - function onMorphWeightChanged(entity, name, weight) { - morphCol.weightTick = morphCol.weightTick + 1 + 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 && !enabled + ToolTip.text: "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) + } + } + } + } + // 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 morph target name:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + 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 + } + 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() + } + } + } + } + } + + // 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. + Repeater { + model: morphCol.targets Row { + width: morphCol.width spacing: 4 - width: parent.width + 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. Text { - text: "Morph Targets (" + morphCol.targetCount + ")" + id: morphNameText + visible: !morphNameEdit.visible + text: modelData color: PropertiesPanelController.textColor - font.pixelSize: 11 - font.bold: true + 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() + } + } + } + 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 + } + Keys.onEscapePressed: { cancelled = true; visible = false } + } + Slider { + id: weightSlider + from: 0; to: 1; stepSize: 0.01 + // name(120) + weight(36) + key(16) + up(16) + + // down(16) + delete(18) + row spacings ≈ 272. + width: parent.width - 272 + // 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 } - 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. + // 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 { - 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 + width: 16; height: 18; radius: 3 anchors.verticalCenter: parent.verticalCenter + visible: morphCol.filter === "" + color: keyMa.containsMouse + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" Text { anchors.centerIn: parent - text: "+ Add…" - color: PropertiesPanelController.textColor - font.pixelSize: 9 + text: "◈" + color: "#88ccff" // matches the dope-sheet morph diamonds + font.pixelSize: 11 } MouseArea { - id: addMa + id: keyMa anchors.fill: parent hoverEnabled: true - enabled: addBtn.canAddFromEdit - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + cursorShape: Qt.PointingHandCursor onClicked: { - addNameField.text = "" - addError.text = "" - addNamePopup.open() + var t = AnimationControlController.sliderValue / 1000.0 + MorphAnimationManager.setMorphWeightKeyframe( + modelData, t, weightSlider.value) } - ToolTip.visible: containsMouse && !enabled - ToolTip.text: "Enter Edit Mode (Tab) to add morph targets from current edit." + ToolTip.visible: containsMouse + ToolTip.text: "Key this weight at the timeline playhead" } } - // Reset all: walks every target and sets weight to 0. + // Move up (▲) — reorder via ReorderMorphTargetsCommand + // (undoable). Disabled on the first row. Reorder is only + // meaningful with no filter applied (the index maps to + // the full list), so hide the arrows while filtering. Rectangle { - width: 60; height: 20; radius: 3 - color: resetMa.containsMouse - ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) - : PropertiesPanelController.controlBgColor - border.color: PropertiesPanelController.borderColor + width: 16; height: 18; radius: 3 anchors.verticalCenter: parent.verticalCenter + visible: morphCol.filter === "" && morphCol.targetCount > 1 + opacity: index > 0 ? 1.0 : 0.3 + color: morphUpMa.containsMouse && index > 0 + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" Text { anchors.centerIn: parent - text: "Reset all" - color: PropertiesPanelController.textColor + text: "▲"; color: PropertiesPanelController.textColor font.pixelSize: 9 } MouseArea { - id: resetMa + id: morphUpMa anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - for (var i = 0; i < morphCol.targets.length; ++i) - MorphAnimationManager.setWeightForSelection(morphCol.targets[i], 0) - } + enabled: index > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: MorphAnimationManager.moveMorphTarget(modelData, -1) } } - } - - // 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 - } - TextField { - id: addNameField - width: 220 - font.pixelSize: 11 - onAccepted: addConfirmMa.confirm() - onTextChanged: addError.text = "" - Component.onCompleted: forceActiveFocus() - } - // 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. + // Move down (▼) — disabled on the last row. + Rectangle { + width: 16; height: 18; radius: 3 + anchors.verticalCenter: parent.verticalCenter + visible: morphCol.filter === "" && morphCol.targetCount > 1 + opacity: index < morphCol.targetCount - 1 ? 1.0 : 0.3 + color: morphDownMa.containsMouse && index < morphCol.targetCount - 1 + ? Qt.lighter(PropertiesPanelController.headerColor, 1.3) + : "transparent" Text { - id: addError - text: "" - visible: text.length > 0 - color: "#d65d5d" - font.pixelSize: 10 - width: 220 - wrapMode: Text.Wrap + anchors.centerIn: parent + text: "▼"; color: PropertiesPanelController.textColor + font.pixelSize: 9 } - 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() - } - } + MouseArea { + id: morphDownMa + anchors.fill: parent + hoverEnabled: true + enabled: index < morphCol.targetCount - 1 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: MorphAnimationManager.moveMorphTarget(modelData, 1) } } - } - - // Filter / search — characters often have 50+ blend - // shapes, scanning a flat list is hopeless without - // a typeahead box. - TextField { - id: filterField - width: parent.width - placeholderText: "Filter targets…" - font.pixelSize: 10 - onTextChanged: morphCol.filter = text - visible: morphCol.targetCount > 6 - } - - // One row per target. Hidden when filter doesn't match. - Repeater { - model: morphCol.targets - Row { - width: morphCol.width - spacing: 4 - visible: morphCol.filter === "" - || modelData.toLowerCase().indexOf(morphCol.filter.toLowerCase()) >= 0 - height: visible ? 22 : 0 - - // 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: 120 - elide: Text.ElideRight - anchors.verticalCenter: parent.verticalCenter - MouseArea { - anchors.fill: parent - onDoubleClicked: { - morphNameEdit.text = modelData - morphNameEdit.visible = true - morphNameEdit.forceActiveFocus() - morphNameEdit.selectAll() - } - } - } - 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 - } - Keys.onEscapePressed: { cancelled = true; visible = false } - } - Slider { - id: weightSlider - 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) - } + // 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: weightSlider.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) } } } diff --git a/src/AlembicImporter.cpp b/src/AlembicImporter.cpp new file mode 100644 index 00000000..3da1f8a1 --- /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 00000000..feb51838 --- /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 00000000..9ad71451 --- /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 f7d058b4..0e00685c 100644 --- a/src/AnimationControlController.cpp +++ b/src/AnimationControlController.cpp @@ -1,5 +1,6 @@ #include "AnimationControlController.h" #include "PropertiesPanelController.h" +#include "MorphAnimationManager.h" #include "SelectionSet.h" #include "Manager.h" #include "SentryReporter.h" @@ -948,23 +949,41 @@ 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; + }; + + if (!appendTrackTimes(MorphAnimationManager::kWeightClipName)) + appendTrackTimes(poseName); // static shape-only clip QVariantMap row; row[QStringLiteral("name")] = QString::fromStdString(poseName); diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 34bce51a..3c9eb2dc 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -2,6 +2,7 @@ #include "CloudCLIPipeline.h" #include "Manager.h" #include "MeshImporterExporter.h" +#include "AlembicImporter.h" #include "AnimationMerger.h" #include "MotionInbetween.h" #include "MotionLibrary.h" @@ -2011,6 +2012,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; @@ -2047,6 +2049,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) { @@ -2151,6 +2154,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 0477eb1c..3f015841 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -135,6 +135,8 @@ HDR/HdrBundledLibrary.cpp MorphAnimationManager.cpp NodeAnimationManager.cpp PoseLibrary.cpp +VertexAnimationManager.cpp +AlembicImporter.cpp ApplyAtlas.cpp EmbeddedTextureCache.cpp NormalMapGenerator.cpp @@ -657,6 +659,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) @@ -789,6 +795,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 4d1e1ae0..d0579793 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 25736556..a26e2998 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 cb3d8d50..997fbe12 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 @@ -796,6 +798,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/MCPServer.cpp b/src/MCPServer.cpp index 59bd4d89..cb0b6d37 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -9,6 +9,7 @@ #include "NormalMapGenerator.h" #include "VATBaker.h" #include "MorphAnimationManager.h" +#include "AlembicImporter.h" #include "NodeAnimationManager.h" #include "PoseLibrary.h" #include "PrimitiveObject.h" @@ -666,6 +667,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}, @@ -709,6 +712,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); @@ -5960,6 +5964,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. // --------------------------------------------------------------------------- @@ -8287,6 +8357,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 ab3ee5f4..df168337 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -267,6 +267,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 4241c93a..cfceb550 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -83,7 +83,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 30cde991..3be55108 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 @@ -58,6 +59,7 @@ THE SOFTWARE. #include "OgreXML/pugixml.hpp" #include "AnimationMerger.h" +#include "MorphAnimationManager.h" #include "Manager.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -908,6 +910,121 @@ static aiAnimation* buildAiAnimation(Ogre::Animation* ogreAnim, const std::strin return anim; } +// Build a morph-weight aiAnimation from the mesh's "MorphAnim" clip so an +// authored blend-shape weight-over-time animation round-trips to glTF. +// +// Ogre stores morph weight animation on ONE mesh Animation named +// `MorphAnimationManager::kWeightClipName` ("MorphAnim"). That clip has one +// VAT_POSE VertexAnimationTrack per submesh target handle (handle 0 = shared +// vertex data, handle si+1 = submesh si's dedicated vertex data). Each +// VertexPoseKeyFrame on a track carries a list of {poseIndex, influence} +// references — influence is the target's weight at that keyframe time; poses +// not referenced at a time are weight 0. +// +// Assimp models this as an aiAnimation whose mMorphMeshChannels holds one +// aiMeshMorphAnim per animated NODE/mesh. Each aiMeshMorphAnim's mName is the +// NODE the target mesh attaches to, and its mKeys are per-distinct-time +// aiMeshMorphKey entries. A key's parallel mValues/mWeights arrays are indexed +// by anim-mesh index (0..mNumAnimMeshes-1 == aiMesh::mAnimMeshes[i]), so we map +// each pose (by name) to its anim-mesh slot on that mesh and scatter the +// keyframe influences into the matching slot (0 elsewhere). +// +// `new` allocations cross the Assimp C-API ownership boundary — aiAnimation's +// dtor frees mMorphMeshChannels and each channel's mKeys (and the per-key +// mValues/mWeights via aiMeshMorphKey's dtor) — so raw new[] matches every +// other aiScene field in this file (no double-free in the manual +// scene->mAnimations cleanup paths, which just delete each aiAnimation). +// +// Returns nullptr (emitting nothing) when there is no weight clip or no +// submesh carries morph targets, so non-morph meshes are entirely unaffected. +static aiAnimation* buildMorphWeightAiAnimation(const aiScene* scene, + const Ogre::MeshPtr& mesh, + const std::string& meshNodeName) +{ + const std::string clip = MorphAnimationManager::kWeightClipName; + if (!mesh->hasAnimation(clip)) return nullptr; + Ogre::Animation* weightClip = mesh->getAnimation(clip); + if (!weightClip) return nullptr; + + const unsigned int numSub = mesh->getNumSubMeshes(); + std::vector morphChannels; + + // One channel per submesh that actually got morph targets (mAnimMeshes). + // All submeshes attach to the single node `meshNodeName` in buildAiScene, + // so every channel carries that node name; the anim-mesh index space is + // per-mesh, which is exactly what a per-mesh channel expresses. + for (unsigned int si = 0; si < numSub; ++si) + { + aiMesh* aiM = scene->mMeshes[si]; + if (!aiM || aiM->mNumAnimMeshes == 0) continue; + + // The weight track for this submesh lives under its target handle. + const Ogre::SubMesh* subMesh = mesh->getSubMesh(si); + const unsigned short targetHandle = subMesh->useSharedVertices + ? 0 + : static_cast(si + 1); + if (!weightClip->hasVertexTrack(targetHandle)) continue; + Ogre::VertexAnimationTrack* track = weightClip->getVertexTrack(targetHandle); + if (!track || track->getAnimationType() != Ogre::VAT_POSE) continue; + + // Map pose name -> anim-mesh slot on this mesh (mAnimMeshes[i].mName is + // the Ogre pose name; see attachMorphTargetsToAiMesh). + std::map> poseNameToSlot; + for (unsigned int i = 0; i < aiM->mNumAnimMeshes; ++i) + if (aiM->mAnimMeshes[i]) + poseNameToSlot[aiM->mAnimMeshes[i]->mName.C_Str()] = i; + + const auto numKf = track->getNumKeyFrames(); + if (numKf == 0) continue; + + auto* channel = new aiMeshMorphAnim(); // NOSONAR — Assimp owns + channel->mName = aiString(meshNodeName); + channel->mNumKeys = static_cast(numKf); + channel->mKeys = new aiMeshMorphKey[numKf]; // NOSONAR — Assimp owns + + const Ogre::PoseList& poseList = mesh->getPoseList(); + for (unsigned short ki = 0; ki < numKf; ++ki) + { + auto* kf = track->getVertexPoseKeyFrame(ki); + aiMeshMorphKey& key = channel->mKeys[ki]; + key.mTime = kf->getTime(); // seconds; parent mTicksPerSecond = 1.0 + // Parallel arrays over ALL anim-meshes on this mesh: mValues[i] is + // the anim-mesh index, mWeights[i] its weight at mTime (default 0). + key.mNumValuesAndWeights = aiM->mNumAnimMeshes; + key.mValues = new unsigned int[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns + key.mWeights = new double[aiM->mNumAnimMeshes]; // NOSONAR — Assimp owns + for (unsigned int i = 0; i < aiM->mNumAnimMeshes; ++i) + { + key.mValues[i] = i; + key.mWeights[i] = 0.0; + } + // Scatter each referenced pose's influence into its anim-mesh slot. + for (const auto& ref : kf->getPoseReferences()) + { + if (ref.poseIndex >= poseList.size()) continue; + const Ogre::Pose* p = poseList[ref.poseIndex]; + if (!p) continue; + auto it = poseNameToSlot.find(p->getName()); + if (it != poseNameToSlot.end()) + key.mWeights[it->second] = static_cast(ref.influence); + } + } + morphChannels.push_back(channel); + } + + if (morphChannels.empty()) return nullptr; + + auto* anim = new aiAnimation(); // NOSONAR — Assimp owns + anim->mName = aiString(std::string(MorphAnimationManager::kWeightClipName)); + anim->mTicksPerSecond = 1.0; // times are seconds, matching buildAiAnimation + anim->mDuration = weightClip->getLength(); + anim->mNumMorphMeshChannels = static_cast(morphChannels.size()); + anim->mMorphMeshChannels = new aiMeshMorphAnim*[anim->mNumMorphMeshChannels]; // NOSONAR — Assimp owns + for (unsigned int ci = 0; ci < anim->mNumMorphMeshChannels; ++ci) + anim->mMorphMeshChannels[ci] = morphChannels[ci]; + return anim; +} + // Build an aiScene directly from an Ogre Entity, bypassing the XML round-trip static aiScene* buildAiScene(const Ogre::Entity* entity) { @@ -1013,12 +1130,33 @@ 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))); + } + + // The node every submesh attaches to (see node wiring above): the dedicated + // "_mesh" node when there's a skeleton, else the root node itself. + const std::string meshNodeName = hasSkeleton + ? std::string(entity->getName()) + "_mesh" + : std::string(entity->getName()); + if (aiAnimation* morphAnim = buildMorphWeightAiAnimation(scene, mesh, meshNodeName)) + animations.push_back(morphAnim); + + 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; @@ -2096,7 +2234,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(); @@ -2673,19 +2827,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() diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 7909694b..16f3e336 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -585,8 +585,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 (*.*)"))); } @@ -1999,6 +2020,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 c9fd2cf5..6e1c44ba 100644 --- a/src/MorphAnimationManager.cpp +++ b/src/MorphAnimationManager.cpp @@ -20,8 +20,11 @@ The MIT License #include #include +#include #include +#include #include +#include #include #include @@ -163,6 +166,134 @@ 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 shared "MorphAnim" +// 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, + bool create) +{ + const int pi = poseIndexForName(mesh, name); + if (pi < 0) return nullptr; + const unsigned short handle = mesh->getPoseList()[pi]->getTarget(); + + const std::string clip = MorphAnimationManager::kWeightClipName; + 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); +} + +} // 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 int pi = poseIndexForName(mesh.get(), name.toStdString()); + if (pi < 0) return false; + Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), 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; for a weight track + // each keyframe references exactly this target's pose at influence = weight. + kf->removeAllPoseReferences(); + kf->addPoseReference(static_cast(pi), w); + + // Extend the clip length to cover the new time. + Ogre::Animation* anim = mesh->getAnimation(kWeightClipName); + 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(); + 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; + + Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), false); + if (!track) return false; + const float t = static_cast(time); + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) { + if (std::abs(track->getKeyFrame(i)->getTime() - t) < 1e-4f) { + track->removeKeyFrame(i); + emit morphTargetsChanged(); + return true; + } + } + return false; +} + +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; + + Ogre::VertexAnimationTrack* track = weightTrackFor(mesh.get(), name.toStdString(), false); + if (!track) return out; + for (unsigned short i = 0; i < track->getNumKeyFrames(); ++i) + out.append(static_cast(track->getKeyFrame(i)->getTime())); + return out; +} + namespace { // Walk the per-submesh edited positions on the EditableMesh and diff @@ -312,3 +443,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 5b2e759b..3dae56af 100644 --- a/src/MorphAnimationManager.h +++ b/src/MorphAnimationManager.h @@ -70,6 +70,28 @@ 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). A single mesh Animation + /// named `kWeightClipName` ("MorphAnim") holds one VAT_POSE track per + /// target's pose; each keyframe references the pose at the influence + /// (= weight) recorded at that time. This is what glTF exports as a + /// morph-weights animation. Distinct from the static per-target Animation + /// (named exactly the target name) that only carries the shape. + + /// Record `weight` for target `name` at `time` seconds on the weight 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 weight keyframe for `name` at (approximately) `time`. + Q_INVOKABLE bool clearMorphWeightKeyframe(const QString& name, double time); + + /// Keyframe times (seconds) for `name`'s weight track on the selection, + /// ascending. Empty if none. For the dope sheet. + Q_INVOKABLE QVariantList morphWeightKeyframeTimes(const QString& name) const; + + /// The weight-animation clip name used across the app + glTF export. + static const char* kWeightClipName; + /// 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 +115,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. diff --git a/src/MorphAnimationManager_test.cpp b/src/MorphAnimationManager_test.cpp index a27ee039..ab0f71e8 100644 --- a/src/MorphAnimationManager_test.cpp +++ b/src/MorphAnimationManager_test.cpp @@ -249,6 +249,46 @@ 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, NoSelectionGivesEmptyList) { auto* m = MorphAnimationManager::instance(); EXPECT_TRUE(m->morphTargetsForSelection().isEmpty()); @@ -338,6 +378,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 dfb1d318..e7bde6ca 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -10,7 +10,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" @@ -772,16 +775,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); } diff --git a/src/VertexAnimationManager.cpp b/src/VertexAnimationManager.cpp new file mode 100644 index 00000000..74c94df6 --- /dev/null +++ b/src/VertexAnimationManager.cpp @@ -0,0 +1,228 @@ +/* +----------------------------------------------------------------------------------- +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(); + return instance(); +} + +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 00000000..16f295f4 --- /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 00000000..eddcb929 --- /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 b57182ba..63b240dc 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 @@ -232,3 +251,57 @@ 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. + 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); + } +} + +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 381b1847..b7ca7d21 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 7351a603..fe118988 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -127,6 +127,7 @@ #include "IsometricSpritesController.h" #include "ImageTo3D/MeshGenController.h" #include "MorphAnimationManager.h" +#include "VertexAnimationManager.h" #include "EditorModeController.h" #include "QtMeshCloudClient.h" #include @@ -866,6 +867,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 e6844bec..4dbd3333 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -131,6 +131,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 @@ -487,6 +489,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