diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c2c918c6..ba8aeff4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,11 +23,13 @@ env: ASSIMP_DIR_VERSION: '6.0' OGRE_VERSION: '14.5.2' # Bump to bust the macOS assimp/ogre caches. The cached OGRE/Assimp SDKs bake - # absolute Xcode SDK paths (e.g. .../usr/lib/libz.tbd) into their CMake export; - # when the macos-latest runner image bumps Xcode, a stale cache hit makes - # build-macos fail with "No rule to make target '/libz.tbd'". Bump - # this whenever the runner's Xcode/SDK changes. - MACOS_CACHE_VERSION: 'xcode26b' + # an absolute Xcode SDK path (e.g. .../usr/lib/libz.tbd) into their CMake + # export. The Pin-Xcode step also pins SDKROOT so CMake's ZLIB resolves under + # the selected Xcode (xcode-select alone didn't stop find_package(ZLIB) from + # picking xcrun's default 26.5 SDK). Bump this whenever the pinned Xcode/SDK + # changes so the SDK is rebuilt against it and stale libz.tbd paths are + # discarded. (sdkpin1 = first build under the SDKROOT-pinned environment.) + MACOS_CACHE_VERSION: 'sdkpin1' jobs: # send-slack-notification: @@ -1580,6 +1582,22 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1603,6 +1621,9 @@ jobs: /usr/local/lib/libzlibstatic.a #key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }} # Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo. + # NOTE: assimp is NOT Xcode-keyed (unlike ogre): it's a plain static lib + # that doesn't bake absolute SDK paths, so one assimp cache works across + # Xcode versions and stays shared so the ogre-rebuild-on-miss can use it. key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} restore-keys: | ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- @@ -1633,6 +1654,22 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1665,7 +1702,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' name: Check out ogre repo @@ -1698,6 +1735,22 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1761,7 +1814,31 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + + # If this runner image's Xcode differs from the one the producer cached + # under, the key above misses. Rebuild OGRE here under THIS job's Xcode so + # the SDK's baked libz.tbd path matches what we link against (self-heals the + # cross-image Xcode mismatch instead of failing on a stale libz.tbd path). + - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' + name: Check out ogre repo (cache miss) + uses: actions/checkout@master + with: + repository: OGRECave/ogre + ref: v${{ env.OGRE_VERSION }} + path: ${{github.workspace}}/ogre + + - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' + name: Build Ogre3D repo (cache miss) + run: | + cd ${{github.workspace}}/ogre/ + sudo cmake -S . -DOGRE_BUILD_PLUGIN_ASSIMP=ON -Dassimp_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }}/ \ + -DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON -DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON \ + -DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF -DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF \ + -DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF -DOGRE_BUILD_COMPONENT_PYTHON=OFF \ + -DOGRE_INSTALL_TOOLS=OFF -DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF -DOGRE_BUILD_LIBS_AS_FRAMEWORKS=OFF \ + -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} + sudo make install -j8 - name: Configure CMake env: diff --git a/CLAUDE.md b/CLAUDE.md index 501739cf..64f0a963 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,9 @@ qtmesh uv model.fbx --info # report current UV channels + UV qtmesh uv model.fbx --info --json # same, as JSON qtmesh uv model.fbx --unwrap -o unwrapped.glb # xatlas auto-UV unwrap (#400). Non-overlapping UVs into UV0. qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb # write into UV1 (lightmap workflow) +qtmesh skin model.fbx --max-influences 4 --falloff 4 -o skinned.fbx # auto skin weights (inverse-distance) for a mesh+skeleton (#402) +qtmesh rig model.obj --skeleton humanoid -o rigged.fbx # native auto-rig: embed a skeleton template into an unrigged mesh (#407) +qtmesh rig model.obj --skeleton humanoid --skin -o rigged.fbx # one-click rig + skin (chains #402); templates: humanoid|biped|quadruped|generic; --up-axis x|y|z (default y) qtmesh cloud login # device flow (prints URL + code); stores session locally qtmesh cloud login --api-key # direct API-key login (CI) qtmesh cloud logout # revoke + clear saved session @@ -117,7 +120,7 @@ qtmesh cloud upload model.fbx [--name Hero] [--include "*.png,*.fbx"] [--exclude qtmesh cloud delete # delete a cloud project ``` -CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`, `cloud`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. +CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`, `rig`, `cloud`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. If Xcode SDK is updated, clear CMake cache (`rm build_local/CMakeCache.txt`) and reconfigure. @@ -273,6 +276,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Real-ESRGAN texture upscaling** (`src/TextureUpscaler.h/cpp` + `AIAssistManager`, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. `TextureUpscaler` is the Ogre-free core (reuses `PbrMapSynth::toNCHW`/`nchwToRgb`): a **scale-aware** overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). `AIAssistManager::upscaleTexture(srcPath, scale, overwrite)` extends the per-model `Map` enum with `UpscaleX2`/`UpscaleX4`, downloads the model on first use (same HF repo), runs, caches `_upscaled_x{2,4}.png` next to the source, and emits `upscaleStarted/Completed/Error`. The Material Editor path is worker-threaded and reports state via `upscaleDownloading` (first-run model fetch) / `upscaleProgress(done,total)` (per tile) / `upscaleCompleted`/`upscaleError`; `cancelUpscale()` flips a shared atomic that the tiling loop's `ProgressFn` checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. **Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, [xinntao](https://github.com/xinntao/Real-ESRGAN))** — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via `scripts/export-realesrgan-onnx.py` (one-time, offline, NOT shipped). Surfaced via **CLI `qtmesh material --texture --upscale {2|4} [-o ]`** (`CLIPipeline::cmdMaterialUpscale`), the MCP `upscale_texture` tool, and **"Upscale 2× / 4×" buttons** in the Material Editor's Texture Properties panel. Sentry breadcrumb category `ai.assist.upscale`. ONNX intra-op threads are set to `hardware_concurrency-1` (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — `PbrMapSynth` stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded. - **LLM-assisted material from a description** (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → `MaterialEditorQML::generateMaterialFromPrompt` → `LLMManager::generateMaterial`); #406 adds the missing **CLI + MCP parity** by reusing that exact path headlessly. The shared core `CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)` resolves a GGUF model (the `--model`/`model` override, else last-used / first available via `LLMManager::scanForModels`+`availableModels`), drives `LLMManager::generateMaterial` synchronously through two `QEventLoop`s (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the `material ` header, parses the script via `MaterialManager::parseScript`, `compile()`s, honors a `pbr_workflow` tag through `RTShaderHelper::applyPbrIfTagged`, and binds the material to every submesh of the entity. The **CLI** `qtmesh material --describe "" [--model ] [-o out]` (`CLIPipeline::cmdMaterialDescribe`) imports → applies → re-exports; the **MCP** `describe_material` tool (`MCPServer::toolDescribeMaterial`, args `{prompt, mesh?, model?, output_path?}`) applies to the named/selected entity in-session and optionally re-exports when `output_path` is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — `LLMManager.cpp` always compiles, so no `#ifdef ENABLE_LOCAL_LLM` guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category `ai.assist.describe_material`. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer. - **SkinWeights** (`src/SkinWeights.h/cpp`, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights. The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is **GPL/copyleft**. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with **zero new dependencies**: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply `1/dist^falloff` weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (`maxInfluenceDistance` × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional `skipUnweightedBones` filters Mixamo helper bones. `replaceExisting=false` enables a merge mode for "fill in missing weights" workflows. Surfaced via `qtmesh skin --max-influences N --falloff F -o out`, MCP `compute_skin_weights`, and the **Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button** (`qml/SkinWeightsDialog.qml`, driven by `SkinWeightsController` singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to `hasSkinnedSelection` so it disables on static (skeleton-less) meshes. The GUI path runs through `ComputeSkinWeightsCommand` (`src/commands/`) so the auto-skin is **undoable** (Ctrl+Z): the command snapshots every submesh's `VertexBoneAssignmentList` (+ the mesh-level shared list) before the first `redo`, runs `computeAndApply`, and on `undo` restores the snapshot and calls `_compileBoneAssignments` to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category `ai.assist.skin_weights`. A future slice can plug libigl BBW in behind `-DENABLE_LIBIGL_BBW` for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip. +- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. - **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%. - **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering. - **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`. diff --git a/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml new file mode 100644 index 00000000..c6e8c584 --- /dev/null +++ b/qml/AutoRigDialog.qml @@ -0,0 +1,288 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import MaterialEditorQML 1.0 +import PropertiesPanel 1.0 + +// Issue #407: top-level Window for native auto-rigging. Same Inspector-styled +// idiom as SkinWeightsDialog / QuadRetopoDialog. Operates on the currently +// selected STATIC entity — the button disables on already-rigged or empty +// selections (AutoRigController.hasRiggableSelection). +Window { + id: dialog + title: "Auto-Rig" + width: 560 + height: 420 + minimumWidth: 480 + minimumHeight: 380 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property var templates: ["humanoid", "biped", "quadruped", "generic"] + property int templateIndex: 0 + property var upAxes: ["x", "y", "z"] + property int upAxisIndex: 1 // +Y default + property bool alsoSkin: true + + property string lastStatus: "" + property bool lastWasError: false + + function open() { + dialog.lastStatus = "" + dialog.lastWasError = false + dialog.show() + dialog.raise() + dialog.requestActivate() + keyCapture.forceActiveFocus() + } + + function runRig() { + if (AutoRigController.busy) return + if (!AutoRigController.hasRiggableSelection) return + const r = AutoRigController.autoRigSelected( + dialog.templates[dialog.templateIndex], + dialog.upAxes[dialog.upAxisIndex], + dialog.alsoSkin) + if (r && r.applied) { + dialog.lastStatus = + "Rigged: " + r.boneCount + " bones, " + + r.verticesSampled + " verts sampled, " + + r.jointsRecentered + " joints recentered" + + (dialog.alsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + dialog.lastWasError = false + } else { + dialog.lastStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + dialog.lastWasError = true + } + } + + Item { + id: keyCapture + anchors.fill: parent + focus: true + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + dialog.close() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + dialog.runRig() + event.accepted = true + } + } + } + + // ── Inline Inspector primitives (match SkinWeightsDialog) ─────────── + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + activeFocusOnTab: buttonEnabled + Accessible.role: Accessible.Button + Accessible.name: btn.label + Keys.onSpacePressed: if (buttonEnabled) btn.clicked() + Keys.onReturnPressed: if (buttonEnabled) btn.clicked() + Keys.onEnterPressed: if (buttonEnabled) btn.clicked() + height: 26 + radius: 3 + color: btnMa.containsMouse && buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: btn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: btn.activeFocus ? 2 : 1 + opacity: buttonEnabled ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + text: btn.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: btnMa + anchors.fill: parent + hoverEnabled: true + enabled: btn.buttonEnabled + cursorShape: btn.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: btn.clicked() + } + } + + component InspectorLabel: Text { + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + component InspectorCheckbox: Rectangle { + id: cb + property string label: "" + property bool checked: false + signal toggled() + activeFocusOnTab: true + Accessible.role: Accessible.CheckBox + Accessible.name: cb.label + Accessible.checked: cb.checked + Keys.onSpacePressed: cb.toggled() + Keys.onReturnPressed: cb.toggled() + Keys.onEnterPressed: cb.toggled() + height: 16 + width: parent ? parent.width : 200 + color: "transparent" + Row { + spacing: 6 + Rectangle { + width: 14; height: 14 + radius: 2 + color: PropertiesPanelController.inputColor + border.color: cb.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: cb.activeFocus ? 2 : 1 + Text { + anchors.centerIn: parent + text: cb.checked ? "✓" : "" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + } + InspectorLabel { text: cb.label } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: cb.toggled() + } + } + + // A minimal segmented picker (no ComboBox dependency, matches the + // hand-rolled Inspector style). + component InspectorSegments: Row { + id: seg + property var options: [] + property int index: 0 + signal picked(int i) + spacing: 4 + Repeater { + model: seg.options + Rectangle { + width: Math.max(60, segText.implicitWidth + 18) + height: 24 + radius: 3 + color: index === seg.index + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: segText + anchors.centerIn: parent + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: seg.picked(index) + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.85 + text: "Embed a skeleton template into the selected unrigged mesh. " + + "Native heuristic (no external deps): maps a proportional joint " + + "graph into the mesh bounds and recentres joints toward the " + + "mesh's medial mass. Works best on roughly upright, manifold, " + + "T/A-pose meshes with +Y up. Already-rigged meshes are not " + + "eligible." + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Skeleton:"; Layout.preferredWidth: 80 } + InspectorSegments { + options: dialog.templates + index: dialog.templateIndex + onPicked: function(i) { dialog.templateIndex = i } + } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Up axis:"; Layout.preferredWidth: 80 } + InspectorSegments { + options: dialog.upAxes + index: dialog.upAxisIndex + onPicked: function(i) { dialog.upAxisIndex = i } + } + InspectorLabel { + text: "(+Y is the in-app default after import)" + opacity: 0.7 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: ""; Layout.preferredWidth: 80 } + InspectorCheckbox { + Layout.fillWidth: true + label: "Also compute skin weights (one-click rig + skin)" + checked: dialog.alsoSkin + onToggled: dialog.alsoSkin = !dialog.alsoSkin + } + } + + Item { Layout.fillHeight: true } + + InspectorLabel { + Layout.fillWidth: true + visible: dialog.lastStatus.length > 0 + text: dialog.lastStatus + wrapMode: Text.WordWrap + color: dialog.lastWasError ? "#cc4444" : "#3a8c3a" + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + InspectorButton { + label: "Close" + Layout.preferredWidth: 90 + onClicked: dialog.close() + } + InspectorButton { + label: AutoRigController.busy ? "Rigging…" : "Auto-Rig" + Layout.preferredWidth: 160 + buttonEnabled: !AutoRigController.busy + && AutoRigController.hasRiggableSelection + onClicked: dialog.runRig() + } + } + } + + Connections { + target: AutoRigController + function onError(msg) { + dialog.lastStatus = "Failed: " + msg + dialog.lastWasError = true + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 1b94886c..919d468e 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -331,6 +331,23 @@ Rectangle { Component.onCompleted: content = skinningToolsComponent } + // ---- Rigging (Animation mode) ---- + // Issue #407: native auto-rig. Shown in Animation Mode for a + // STATIC (skeleton-less) selection — embedding a skeleton is the + // step that turns a static mesh into an animatable one, so it + // belongs next to Skinning. Gated on hasRiggableSelection (a + // static mesh); already-rigged meshes show the Skinning section + // instead. + CollapsibleSection { + title: "Rigging" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && AutoRigController.hasRiggableSelection + expanded: false + + Component.onCompleted: content = riggingToolsComponent + } + // ---- Texture Paint (Material mode) ---- // (Brush color/radius/strength/falloff live on the toolbar // paint-brush popup. The Inspector panel keeps only the @@ -1267,6 +1284,74 @@ Rectangle { } } + // ---- Rigging Tools Content (Animation mode) ---- + // Issue #407: native auto-rig. The "Auto-Rig…" button opens the dialog + // (template picker + skin checkbox); it disables on non-static meshes + // (AutoRigController.hasRiggableSelection). + Component { + id: riggingToolsComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + opacity: 0.8 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: "Embed a skeleton template (humanoid / biped / quadruped / " + + "generic) into the selected unrigged mesh, optionally skinning " + + "it in one click. Best on upright, manifold, T/A-pose meshes." + } + + Rectangle { + id: rigBtn + width: Math.min(parent.width - 16, rigLabel.implicitWidth + 16) + height: 26 + radius: 3 + opacity: AutoRigController.hasRiggableSelection ? 1.0 : 0.45 + color: rigMa.containsMouse && AutoRigController.hasRiggableSelection + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + activeFocusOnTab: AutoRigController.hasRiggableSelection + Accessible.role: Accessible.Button + Accessible.name: "Auto-Rig" + Keys.onSpacePressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + Keys.onReturnPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + Keys.onEnterPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + border.color: rigBtn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: rigBtn.activeFocus ? 2 : 1 + + Text { + id: rigLabel + anchors.centerIn: parent + text: "Auto-Rig…" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: rigMa + anchors.fill: parent + hoverEnabled: true + enabled: AutoRigController.hasRiggableSelection + cursorShape: AutoRigController.hasRiggableSelection + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: root.openAutoRigDialog() + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: AutoRigController.hasRiggableSelection + ? "Generate a skeleton for this static mesh by embedding a template." + : "Select a static (unrigged) mesh first." + } + } + } + } + // ---- Edit Mode Tools Content ---- Component { id: editModeToolsComponent @@ -4104,6 +4189,26 @@ Rectangle { } } + // Issue #407: native auto-rig dialog. Same lazy-load idiom. + Loader { + id: autoRigLoader + active: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/AutoRigDialog.qml" + onLoaded: if (item && item.open) item.open() + } + function openAutoRigDialog() { + if (!autoRigLoader.active) { + autoRigLoader.active = true + } else if (autoRigLoader.item) { + autoRigLoader.item.open() + } else if (autoRigLoader.status === Loader.Error) { + // Failed load left active=true / item=null — reset so a retry works. + autoRigLoader.active = false + autoRigLoader.active = true + } + } + Loader { id: isometricSpritesLoader active: false diff --git a/src/AppLaunchHandler.cpp b/src/AppLaunchHandler.cpp index b38a0292..b9c3e8be 100644 --- a/src/AppLaunchHandler.cpp +++ b/src/AppLaunchHandler.cpp @@ -26,8 +26,8 @@ bool isCliSubcommand(const QString& arg) QStringLiteral("decimate"), QStringLiteral("atlas"), QStringLiteral("atlas-apply"), QStringLiteral("optimize"), QStringLiteral("bake-vertex-colors"), QStringLiteral("vat"), QStringLiteral("uv"), QStringLiteral("retopo"), - QStringLiteral("skin"), QStringLiteral("morph"), QStringLiteral("nodeanim"), - QStringLiteral("cloud"), + QStringLiteral("skin"), QStringLiteral("rig"), QStringLiteral("morph"), + QStringLiteral("nodeanim"), QStringLiteral("cloud"), }; return kSubcommands.contains(arg); } diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp new file mode 100644 index 00000000..e995cbf3 --- /dev/null +++ b/src/AutoRig.cpp @@ -0,0 +1,397 @@ +#include "AutoRig.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// A template joint literal: name, parent index, normalised x/y/z in +// [0,1]^3 (y = up), and whether the refinement step recentres it. +struct TJ { const char* name; int parent; double x, y, z; bool recenter; }; + +// --- Skeleton templates ----------------------------------------------------- +// +// Positions are in a normalised unit box: x in [0,1] left→right, y in +// [0,1] down→up, z in [0,1] back→front. The mesh's actual up axis is +// remapped from +Y at fit time via Options::upAxis. 0.5 is centre. + +// Humanoid (≈ Mixamo-lite): pelvis → spine → chest → neck → head, plus +// symmetric shoulder/arm and hip/leg chains. Limb tips keep their +// proportional position (recenter=false) so they reach to the silhouette. +const TJ kHumanoid[] = { + {"Hips", -1, 0.50, 0.52, 0.50, true}, + {"Spine", 0, 0.50, 0.62, 0.50, true}, + {"Chest", 1, 0.50, 0.72, 0.50, true}, + {"Neck", 2, 0.50, 0.84, 0.50, true}, + {"Head", 3, 0.50, 0.92, 0.50, true}, + // Left arm (model's left = +x). + {"LeftShoulder", 2, 0.60, 0.78, 0.50, true}, + {"LeftArm", 5, 0.70, 0.78, 0.50, false}, + {"LeftForeArm", 6, 0.82, 0.78, 0.50, false}, + {"LeftHand", 7, 0.93, 0.78, 0.50, false}, + // Right arm (-x). + {"RightShoulder",2, 0.40, 0.78, 0.50, true}, + {"RightArm", 9, 0.30, 0.78, 0.50, false}, + {"RightForeArm",10, 0.18, 0.78, 0.50, false}, + {"RightHand", 11, 0.07, 0.78, 0.50, false}, + // Left leg. + {"LeftUpLeg", 0, 0.58, 0.50, 0.50, true}, + {"LeftLeg", 13, 0.58, 0.27, 0.50, false}, + {"LeftFoot", 14, 0.58, 0.04, 0.55, false}, + // Right leg. + {"RightUpLeg", 0, 0.42, 0.50, 0.50, true}, + {"RightLeg", 16, 0.42, 0.27, 0.50, false}, + {"RightFoot", 17, 0.42, 0.04, 0.55, false}, +}; + +// Biped: spine + 2 legs + short arm stubs (simpler/cheaper than humanoid). +const TJ kBiped[] = { + {"Hips", -1, 0.50, 0.52, 0.50, true}, + {"Spine", 0, 0.50, 0.68, 0.50, true}, + {"Head", 1, 0.50, 0.90, 0.50, true}, + {"LeftArm", 1, 0.68, 0.74, 0.50, false}, + {"RightArm", 1, 0.32, 0.74, 0.50, false}, + {"LeftUpLeg", 0, 0.58, 0.50, 0.50, true}, + {"LeftFoot", 5, 0.58, 0.04, 0.55, false}, + {"RightUpLeg", 0, 0.42, 0.50, 0.50, true}, + {"RightFoot", 7, 0.42, 0.04, 0.55, false}, +}; + +// Quadruped: a horizontal spine (front→back along +z), 4 legs, head, tail. +// Body lies low; "up" is still +y. Front of the body = high z. +const TJ kQuadruped[] = { + {"SpineFront", -1, 0.50, 0.55, 0.70, true}, + {"SpineMid", 0, 0.50, 0.55, 0.50, true}, + {"SpineBack", 1, 0.50, 0.55, 0.30, true}, + {"Neck", 0, 0.50, 0.62, 0.82, true}, + {"Head", 3, 0.50, 0.66, 0.95, true}, + {"Tail", 2, 0.50, 0.55, 0.08, false}, + // Front legs (high z). + {"FrontLeftUpLeg", 0, 0.62, 0.45, 0.72, true}, + {"FrontLeftFoot", 6, 0.62, 0.04, 0.72, false}, + {"FrontRightUpLeg", 0, 0.38, 0.45, 0.72, true}, + {"FrontRightFoot", 8, 0.38, 0.04, 0.72, false}, + // Back legs (low z). + {"BackLeftUpLeg", 2, 0.62, 0.45, 0.30, true}, + {"BackLeftFoot", 10, 0.62, 0.04, 0.30, false}, + {"BackRightUpLeg", 2, 0.38, 0.45, 0.30, true}, + {"BackRightFoot", 12, 0.38, 0.04, 0.30, false}, +}; + +// Generic fallback: a 3-joint vertical spine. Always succeeds. +const TJ kGeneric[] = { + {"Root", -1, 0.50, 0.05, 0.50, true}, + {"Spine", 0, 0.50, 0.50, 0.50, true}, + {"Top", 1, 0.50, 0.95, 0.50, true}, +}; + +std::vector toJoints(const TJ* arr, size_t n) +{ + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + AutoRig::Joint j; + j.name = QString::fromUtf8(arr[i].name); + j.parent = arr[i].parent; + j.pos = {arr[i].x, arr[i].y, arr[i].z}; + j.recenter = arr[i].recenter; + out.push_back(std::move(j)); + } + return out; +} + +} // namespace + +// Out-of-line so the {} default args on the static methods resolve to a +// constructor call (not class-definition-time aggregate init). The member +// initializers in the header supply the actual default values. +AutoRig::Options::Options() = default; + +std::vector AutoRig::templateJoints(Template tmpl) +{ + switch (tmpl) { + case Template::Humanoid: return toJoints(kHumanoid, std::size(kHumanoid)); + case Template::Biped: return toJoints(kBiped, std::size(kBiped)); + case Template::Quadruped: return toJoints(kQuadruped, std::size(kQuadruped)); + case Template::Generic: return toJoints(kGeneric, std::size(kGeneric)); + } + return toJoints(kGeneric, std::size(kGeneric)); +} + +std::vector AutoRig::fitTemplate(const std::vector& tmpl, + const float* verts, + int vertexCount, + const Options& opts, + int* outRecentered) +{ + std::vector placed = tmpl; + if (outRecentered) *outRecentered = 0; + if (!verts || vertexCount <= 0 || tmpl.empty()) return placed; + + // 1. AABB of the vertex cloud. + double mn[3] = { 1e300, 1e300, 1e300}; + double mx[3] = {-1e300, -1e300, -1e300}; + for (int i = 0; i < vertexCount; ++i) { + for (int a = 0; a < 3; ++a) { + const double v = verts[3 * i + a]; + mn[a] = std::min(mn[a], v); + mx[a] = std::max(mx[a], v); + } + } + double ext[3]; + for (int a = 0; a < 3; ++a) ext[a] = std::max(1e-9, mx[a] - mn[a]); + + const int up = std::clamp(opts.upAxis, 0, 2); + // The two in-plane axes (everything that isn't "up"). + const int p0 = (up == 0) ? 1 : 0; + const int p1 = (up == 2) ? 1 : 2; + + // The template's y coordinate is "up"; its x,z are the in-plane axes. + // Map template axis -> world axis so the box orients to the mesh's up. + auto tmplAxisToWorld = [&](int tAxis) { + // tAxis: 0=template-x, 1=template-y(up), 2=template-z + if (tAxis == 1) return up; + return (tAxis == 0) ? p0 : p1; + }; + + // 2. Map each joint's normalised position into the AABB. + for (auto& j : placed) { + std::array world = {0, 0, 0}; + for (int tAxis = 0; tAxis < 3; ++tAxis) { + const int w = tmplAxisToWorld(tAxis); + world[w] = mn[w] + j.pos[tAxis] * ext[w]; + } + j.pos = world; + } + + // 3. Recentre flagged joints toward the mesh's in-plane mass at their + // up-height (pulls the spine onto the medial line, lands limb roots + // inside the silhouette). + const double slab = std::clamp(opts.slabFraction, 1e-3, 0.5) * ext[up]; + int recentered = 0; + for (auto& j : placed) { + if (!j.recenter) continue; + const double y = j.pos[up]; + double sum0 = 0, sum1 = 0; + long long n = 0; + for (int i = 0; i < vertexCount; ++i) { + if (std::abs(static_cast(verts[3 * i + up]) - y) > slab) continue; + sum0 += verts[3 * i + p0]; + sum1 += verts[3 * i + p1]; + ++n; + } + if (n > 0) { + // Blend toward the slab centroid (0.75) but keep a little of the + // template's lateral intent so symmetric joints don't all collapse + // onto the exact centre line. + const double c0 = sum0 / static_cast(n); + const double c1 = sum1 / static_cast(n); + const double kBlend = 0.75; + j.pos[p0] = kBlend * c0 + (1.0 - kBlend) * j.pos[p0]; + j.pos[p1] = kBlend * c1 + (1.0 - kBlend) * j.pos[p1]; + ++recentered; + } + } + if (outRecentered) *outRecentered = recentered; + return placed; +} + +namespace { + +// Tightly read POSITION floats out of a VertexData (same idiom as +// SkinWeights::extractPositions). Appends to `out`. +bool appendPositions(Ogre::VertexData* vd, std::vector& out) +{ + if (!vd) return false; + const auto* posElem = + vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!posElem) return false; + auto vbuf = vd->vertexBufferBinding->getBuffer(posElem->getSource()); + if (!vbuf || vd->vertexCount == 0) return false; + const size_t base0 = out.size(); + out.resize(base0 + static_cast(vd->vertexCount) * 3); + const size_t stride = vbuf->getVertexSize(); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!base) { + // Lock can fail (write-only buffer with no shadow copy, etc.). Shrink + // back to the pre-grow size so the unread slots don't inflate vcount. + out.resize(base0); + return false; + } + for (size_t i = 0; i < vd->vertexCount; ++i) { + float* p = nullptr; + posElem->baseVertexPointerToElement(base + i * stride, &p); + out[base0 + 3 * i + 0] = p[0]; + out[base0 + 3 * i + 1] = p[1]; + out[base0 + 3 * i + 2] = p[2]; + } + vbuf->unlock(); + return true; +} + +} // namespace + +AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) +{ + Report report; + report.templateName = templateToString(opts.tmpl); + + if (!entity || !entity->getMesh()) { + report.error = QStringLiteral("no mesh to rig"); + return report; + } + Ogre::MeshPtr mesh = entity->getMesh(); + report.meshName = QString::fromStdString(mesh->getName()); + + if (mesh->hasSkeleton()) { + report.error = QStringLiteral( + "mesh already has a skeleton — auto-rig only applies to unrigged " + "(static) meshes"); + return report; + } + + // Gather all vertex positions (shared + per-submesh). + std::vector verts; + if (mesh->sharedVertexData) appendPositions(mesh->sharedVertexData, verts); + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (sub && !sub->useSharedVertices && sub->vertexData) + appendPositions(sub->vertexData, verts); + } + const int vcount = static_cast(verts.size() / 3); + if (vcount == 0) { + report.error = QStringLiteral("mesh has no readable vertex positions"); + return report; + } + report.verticesSampled = vcount; + + // Fit the template. + int recentered = 0; + const std::vector tmpl = templateJoints(opts.tmpl); + const std::vector placed = + fitTemplate(tmpl, verts.data(), vcount, opts, &recentered); + report.jointsRecentered = recentered; + + // Build the Ogre skeleton. Bone POSITIONS are parent-relative in Ogre, + // so each child's setPosition is its world pos minus its parent's world + // pos. createBone(name, handle) — handle == index. + auto& skelMgr = Ogre::SkeletonManager::getSingleton(); + const std::string skelName = mesh->getName() + "_autorig"; + if (skelMgr.resourceExists(skelName)) + skelMgr.remove(skelName); + Ogre::SkeletonPtr skel; + try { + skel = skelMgr.create( + skelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + std::vector bones(placed.size(), nullptr); + for (size_t i = 0; i < placed.size(); ++i) + bones[i] = skel->createBone(placed[i].name.toStdString(), + static_cast(i)); + for (size_t i = 0; i < placed.size(); ++i) { + const Joint& j = placed[i]; + Ogre::Vector3 local( + static_cast(j.pos[0]), + static_cast(j.pos[1]), + static_cast(j.pos[2])); + if (j.parent >= 0 && static_cast(j.parent) < placed.size()) { + bones[j.parent]->addChild(bones[i]); + const Joint& pj = placed[j.parent]; + local -= Ogre::Vector3( + static_cast(pj.pos[0]), + static_cast(pj.pos[1]), + static_cast(pj.pos[2])); + } + bones[i]->setPosition(local); + bones[i]->setOrientation(Ogre::Quaternion::IDENTITY); + } + skel->setBindingPose(); + + // Bind the skeleton to the mesh, then force the entity to + // re-initialise so it acquires a SkeletonInstance. Without the + // _initialise(true), the already-created Ogre::Entity keeps + // hasSkeleton()==false and BOTH exporters (FBXExporter and the + // Assimp glTF/FBX path gate on entity->hasSkeleton()) would drop + // the new rig — the skeleton would exist on the mesh but never + // reach the wire. (Same refresh EditableMesh / EditModeController + // do after mutating an entity's mesh.) + mesh->_notifySkeleton(skel); + entity->_initialise(true); + report.skeletonName = QString::fromStdString(skelName); + report.boneCount = static_cast(placed.size()); + report.applied = true; + } catch (const Ogre::Exception& e) { + report.error = QStringLiteral("Ogre error building skeleton: %1") + .arg(QString::fromStdString(e.getFullDescription())); + // Detach the half-built skeleton from the mesh BEFORE removing the + // resource. _notifySkeleton(skel) ran before entity->_initialise; if + // the latter threw, the mesh still references the skeleton, so + // mesh->hasSkeleton() would stay true — a later rigEntity() would bail + // with "mesh already has a skeleton" and exporters could pick up the + // half-built rig. Reset it to a clean static mesh. + mesh->_notifySkeleton(Ogre::SkeletonPtr()); + if (skel && skelMgr.resourceExists(skelName)) skelMgr.remove(skelName); + report.applied = false; + } + return report; +} + +QString AutoRig::templateToString(Template t) +{ + switch (t) { + case Template::Humanoid: return QStringLiteral("humanoid"); + case Template::Biped: return QStringLiteral("biped"); + case Template::Quadruped: return QStringLiteral("quadruped"); + case Template::Generic: return QStringLiteral("generic"); + } + return QStringLiteral("generic"); +} + +AutoRig::Template AutoRig::templateFromString(const QString& s) +{ + const QString l = s.trimmed().toLower(); + if (l == "humanoid") return Template::Humanoid; + if (l == "biped") return Template::Biped; + if (l == "quadruped" || l == "quad") return Template::Quadruped; + if (l == "generic") return Template::Generic; + return Template::Humanoid; // default +} + +QJsonObject AutoRig::reportToJson(const Report& r) +{ + QJsonObject o; + o["applied"] = r.applied; + o["meshName"] = r.meshName; + o["skeletonName"] = r.skeletonName; + o["template"] = r.templateName; + o["boneCount"] = r.boneCount; + o["verticesSampled"] = r.verticesSampled; + o["jointsRecentered"] = r.jointsRecentered; + if (!r.error.isEmpty()) o["error"] = r.error; + return o; +} + +QString AutoRig::reportToText(const Report& r) +{ + if (!r.applied) + return QStringLiteral("Auto-rig failed: %1\n") + .arg(r.error.isEmpty() ? QStringLiteral("unknown error") : r.error); + return QStringLiteral( + "Auto-rigged %1 with the '%2' template.\n" + " bones: %3\n vertices sampled: %4\n joints recentered: %5\n") + .arg(r.meshName, r.templateName) + .arg(r.boneCount).arg(r.verticesSampled).arg(r.jointsRecentered); +} diff --git a/src/AutoRig.h b/src/AutoRig.h new file mode 100644 index 00000000..c892b6fc --- /dev/null +++ b/src/AutoRig.h @@ -0,0 +1,138 @@ +#ifndef AUTO_RIG_H +#define AUTO_RIG_H + +#include +#include +#include +#include +#include + +namespace Ogre { + class Entity; + class Mesh; + class Skeleton; +} + +// Native automatic rigging — predicts a skeleton for an unrigged mesh +// (issue #407, epic #397). +// +// The issue proposes wrapping **Pinocchio** (Baran & Popović, SIGGRAPH +// 2007). Pinocchio's *core library* is **LGPL-2.1-or-later** (only its +// demo CLI is MIT). Statically vendoring an LGPL library imposes +// relink / object-file obligations that conflict with this project's +// statically-linked, permissively-redistributed binaries (Homebrew / +// Snap / WinGet / Docker) and its permissive-license stance — the same +// reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs +// GPL TetGen) shipped native heuristics instead. Pinocchio's *algorithm* +// (embed a skeleton template into the mesh interior via a distance field) +// is published and unencumbered; only its code is LGPL, so this is a +// from-scratch native implementation of the approach with **zero new +// dependencies**. +// +// Algorithm (heuristic embedding): +// 1. Read mesh vertices → axis-aligned bounding box (AABB) + the up +// axis (default +Y). +// 2. Each skeleton template is a proportional joint graph expressed in +// a normalised unit box [0,1]^3 (origin = min corner, y = up). +// Map every joint's normalised position into the mesh AABB. +// 3. Refine: for each joint, recentre it toward the mesh's mass at +// that height by snapping its in-plane (non-up) coordinates to the +// centroid of the vertices in a thin slab around the joint's up +// coordinate. This pulls the spine onto the body's medial line and +// lands limb roots inside the silhouette instead of on the AABB +// shell. Joints whose slab is empty keep their AABB-proportional +// position. +// +// The result is an Ogre::Skeleton in bind pose, ready to bind to the +// mesh and (optionally) feed into #402 SkinWeights for a one-click +// rig + skin. **Quality limits** (documented per the issue): like +// Pinocchio, this works best on roughly upright, single-component, +// manifold, T/A-pose meshes whose up axis is +Y. It is a heuristic — it +// does not detect limbs from topology, so exotic proportions or non- +// upright poses can misplace joints. + +class AutoRig { +public: + // Built-in skeleton templates. + enum class Template { + Humanoid, // pelvis/spine/head + 2 arms + 2 legs (≈ Mixamo-lite) + Biped, // simplified humanoid: spine + 2 legs + stub arms + Quadruped, // spine + 4 legs + head + tail + Generic // a simple 3-joint spine chain (fallback for anything) + }; + + // One joint of a template / placed skeleton. + struct Joint { + QString name; + int parent = -1; // index into the joint list (-1 = root) + // For a TEMPLATE: normalised position in the unit box [0,1]^3. + // For a PLACED skeleton: world-space position in mesh local space. + std::array pos = {0, 0, 0}; + // When true, the refinement step recentres this joint's in-plane + // coords toward the mesh slab centroid (spine/limb-root joints). + // When false, the joint keeps its proportional position (e.g. the + // tip of a limb, which should reach toward the AABB edge). + bool recenter = true; + }; + + struct Options { + // NOTE: declared (not defined) here so `Options{}` default args on the + // member functions below don't force aggregate init of this nested + // struct while the enclosing AutoRig class is still incomplete (which + // GCC rejects: "default member initializer for 'tmpl' needed ..."). + Options(); + Template tmpl = Template::Humanoid; + // Up axis: 0=X, 1=Y, 2=Z. Default +Y (the in-app / glTF / FBX + // convention after import normalisation). + int upAxis = 1; + // Slab half-thickness for the centroid recentre, as a fraction of + // the mesh extent along the up axis. Larger = smoother spine, + // less responsive to local mass. Range (0, 0.5]; default 0.06. + double slabFraction = 0.06; + }; + + struct Report { + QString meshName; + QString skeletonName; + QString templateName; + int boneCount = 0; + int verticesSampled = 0; + int jointsRecentered = 0; + bool applied = false; + QString error; + }; + + // --- Ogre-facing entry point (CLI / MCP / GUI) ----------------------- + + // Generate a skeleton from `opts.tmpl`, fit it to `entity`'s mesh, + // bind it (mesh->_notifySkeleton + setBindingPose), and return a + // report. The entity must be a static (skeleton-less) mesh — an + // already-rigged mesh returns applied=false with an error (unless + // it has no usable geometry). After this returns applied=true, the + // caller may chain SkinWeights::computeAndApply(entity) for weights. + static Report rigEntity(Ogre::Entity* entity, const Options& opts = {}); + + // --- Pure-data core (unit-testable, no Ogre) ------------------------- + + // The proportional joint graph for a template (positions in [0,1]^3). + static std::vector templateJoints(Template tmpl); + + // Fit `templateJoints` to a vertex cloud: map into the AABB, then + // recentre toward per-slab centroids. `vertexPositions` is tightly + // packed xyz (3 floats per vertex). Returns placed joints in the + // same order/parenting as the template, positions now in mesh local + // space. `outRecentered` (optional) receives the count of joints + // that were recentred against a non-empty slab. + static std::vector fitTemplate(const std::vector& tmpl, + const float* vertexPositions, + int vertexCount, + const Options& opts, + int* outRecentered = nullptr); + + static QString templateToString(Template t); + static Template templateFromString(const QString& s); + static QJsonObject reportToJson(const Report& r); + static QString reportToText(const Report& r); +}; + +#endif // AUTO_RIG_H diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp new file mode 100644 index 00000000..cfbe9abd --- /dev/null +++ b/src/AutoRigController.cpp @@ -0,0 +1,137 @@ +#include "AutoRigController.h" +#include "AutoRig.h" +#include "SkinWeights.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include + +AutoRigController* AutoRigController::m_pSingleton = nullptr; + +AutoRigController* AutoRigController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AutoRigController(); + return m_pSingleton; +} + +AutoRigController* AutoRigController::qmlInstance(QQmlEngine* engine, QJSEngine*) +{ + Q_UNUSED(engine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AutoRigController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +AutoRigController::AutoRigController() : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &AutoRigController::selectionChanged); +} + +bool AutoRigController::hasRiggableSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + const auto entities = sel->getResolvedEntities(); + if (entities.isEmpty()) return false; + Ogre::Entity* first = entities.first(); + if (!first || !first->getMesh()) return false; + // Riggable == static (no skeleton yet). An already-skinned mesh is + // intentionally excluded (re-rigging would wipe its existing rig). + return first->getMesh()->getSkeleton() == nullptr; +} + +QVariantMap AutoRigController::autoRigSelected(const QString& templateName, + const QString& upAxis, + bool alsoSkin) +{ + QVariantMap result; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Auto-rig requested (%1, up=%2%3)") + .arg(templateName, upAxis, + alsoSkin ? QStringLiteral(", +skin") : QString())); + + auto* sel = SelectionSet::getSingleton(); + const auto entities = sel ? sel->getResolvedEntities() : QList{}; + if (entities.isEmpty()) { + const auto msg = QStringLiteral("No mesh selected."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + Ogre::Entity* entity = entities.first(); + if (!entity || !entity->getMesh()) { + const auto msg = QStringLiteral("Selected entity is no longer valid."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + + AutoRig::Options opts; + opts.tmpl = AutoRig::templateFromString(templateName); + const QString ax = upAxis.trimmed().toLower(); + if (ax == QStringLiteral("x")) opts.upAxis = 0; + else if (ax == QStringLiteral("z")) opts.upAxis = 2; + else opts.upAxis = 1; // y (default) + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("UI auto-rig entity=%1 template=%2") + .arg(QString::fromStdString(entity->getName()), + AutoRig::templateToString(opts.tmpl))); + + m_busy = true; + emit busyChanged(); + + AutoRig::Report report; + bool skinned = false; + try { + report = AutoRig::rigEntity(entity, opts); + if (report.applied && alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) + report.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } + } catch (const Ogre::Exception& e) { + m_busy = false; + emit busyChanged(); + const auto msg = QString::fromStdString(e.getFullDescription()); + emit error(QStringLiteral("Ogre error: %1").arg(msg)); + result["applied"] = false; + result["error"] = msg; + return result; + } + + m_busy = false; + emit busyChanged(); + emit selectionChanged(); // skeleton state changed → refresh button bindings + + result["applied"] = report.applied; + result["meshName"] = report.meshName; + result["skeletonName"] = report.skeletonName; + result["template"] = report.templateName; + result["boneCount"] = report.boneCount; + result["verticesSampled"] = report.verticesSampled; + result["jointsRecentered"] = report.jointsRecentered; + result["skinned"] = skinned; + if (!report.error.isEmpty()) result["error"] = report.error; + + if (report.applied) emit rigged(result); + else emit error(report.error.isEmpty() + ? QStringLiteral("Auto-rig failed") : report.error); + + return result; +} diff --git a/src/AutoRigController.h b/src/AutoRigController.h new file mode 100644 index 00000000..3a1ecfc9 --- /dev/null +++ b/src/AutoRigController.h @@ -0,0 +1,55 @@ +#ifndef AUTO_RIG_CONTROLLER_H +#define AUTO_RIG_CONTROLLER_H + +#include +#include +#include + +// QML-facing singleton for native auto-rigging (issue #407). +// Wraps `AutoRig::rigEntity` (+ optional `SkinWeights::computeAndApply`) +// and exposes selection state so the Animation-Mode button can disable +// itself when the selection isn't a riggable static mesh. +class AutoRigController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + // True when the selected entity is a STATIC (skeleton-less) mesh — + // the only thing auto-rig can sensibly act on. Already-rigged meshes + // and empty selections disable the button. + Q_PROPERTY(bool hasRiggableSelection READ hasRiggableSelection NOTIFY selectionChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + +public: + static AutoRigController* instance(); + static AutoRigController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + bool hasRiggableSelection() const; + bool busy() const { return m_busy; } + + /// Auto-rig the first resolved selected entity with `templateName` + /// (humanoid / biped / quadruped / generic). When `alsoSkin` is true, + /// chains SkinWeights::computeAndApply so the mesh deforms immediately. + /// Returns a QVariantMap mirroring AutoRig::Report (+ a `skinned` bool). + /// Emits `rigged(report)` on success or `error(msg)` on failure. + Q_INVOKABLE QVariantMap autoRigSelected(const QString& templateName, + const QString& upAxis, + bool alsoSkin); + +signals: + void selectionChanged(); + void busyChanged(); + void rigged(const QVariantMap& report); + void error(const QString& message); + +private: + AutoRigController(); + ~AutoRigController() override = default; + + static AutoRigController* m_pSingleton; + bool m_busy = false; +}; + +#endif // AUTO_RIG_CONTROLLER_H diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp new file mode 100644 index 00000000..5edfa395 --- /dev/null +++ b/src/AutoRig_test.cpp @@ -0,0 +1,157 @@ +// Unit tests for AutoRig (#407). The pure-data core (templateJoints / +// fitTemplate) needs no Ogre/GL context, so these run everywhere — unlike +// rigEntity() which needs a loaded mesh (covered by the CLI coverage test +// under Xvfb on CI). + +#include + +#include +#include + +#include "AutoRig.h" + +namespace { + +// Build a synthetic upright "humanoid-ish" point cloud: 2 units tall (y), +// ~1 wide at the shoulders, narrow elsewhere, centred on x/z=0. +std::vector uprightCloud() +{ + std::vector v; + for (int i = 0; i < 2000; ++i) { + const float y = (static_cast(i) / 2000.0f) * 2.0f; + const float w = (y > 1.4f && y < 1.7f) ? 0.9f : 0.35f; // shoulders bulge + for (int s = -1; s <= 1; s += 2) { + v.push_back(s * w * 0.5f); + v.push_back(y); + v.push_back(0.0f); + } + } + return v; +} + +} // namespace + +TEST(AutoRigCore, TemplatesAreNonEmptyAndWellParented) +{ + for (auto t : {AutoRig::Template::Humanoid, AutoRig::Template::Biped, + AutoRig::Template::Quadruped, AutoRig::Template::Generic}) { + const auto js = AutoRig::templateJoints(t); + ASSERT_FALSE(js.empty()); + // Exactly one root; every non-root parent index is a valid earlier joint. + int roots = 0; + for (size_t i = 0; i < js.size(); ++i) { + if (js[i].parent < 0) { ++roots; continue; } + EXPECT_GE(js[i].parent, 0); + EXPECT_LT(static_cast(js[i].parent), js.size()); + EXPECT_LT(static_cast(js[i].parent), i) + << "parent must precede child for single-pass bone build"; + // Normalised template coords stay in [0,1]. + for (int a = 0; a < 3; ++a) { + EXPECT_GE(js[i].pos[a], 0.0); + EXPECT_LE(js[i].pos[a], 1.0); + } + } + EXPECT_EQ(roots, 1) << "template must have exactly one root"; + } +} + +TEST(AutoRigCore, HumanoidHasExpectedBoneCount) +{ + EXPECT_EQ(AutoRig::templateJoints(AutoRig::Template::Humanoid).size(), 19u); + EXPECT_EQ(AutoRig::templateJoints(AutoRig::Template::Generic).size(), 3u); +} + +TEST(AutoRigCore, FitPlacesAllJointsInsideAABB) +{ + const auto cloud = uprightCloud(); + const int n = static_cast(cloud.size() / 3); + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + + AutoRig::Options o; + o.tmpl = AutoRig::Template::Humanoid; + o.upAxis = 1; + int recentered = 0; + const auto placed = AutoRig::fitTemplate(tmpl, cloud.data(), n, o, &recentered); + + ASSERT_EQ(placed.size(), tmpl.size()); + EXPECT_GT(recentered, 0) << "spine/limb-root joints should recentre on a real cloud"; + + // Cloud AABB: x in [-0.45, 0.45], y in [0, 2], z == 0. + for (const auto& j : placed) { + EXPECT_GE(j.pos[1], -1e-3); + EXPECT_LE(j.pos[1], 2.0 + 1e-3) << j.name.toStdString() << " y out of AABB"; + EXPECT_GE(j.pos[0], -0.45 - 1e-3); + EXPECT_LE(j.pos[0], 0.45 + 1e-3) << j.name.toStdString() << " x out of AABB"; + } +} + +TEST(AutoRigCore, FitRespectsVerticalOrdering) +{ + const auto cloud = uprightCloud(); + const int n = static_cast(cloud.size() / 3); + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options o; + const auto placed = AutoRig::fitTemplate(tmpl, cloud.data(), n, o, nullptr); + + auto yOf = [&](const QString& name) -> double { + for (const auto& j : placed) if (j.name == name) return j.pos[1]; + return -1e9; + }; + // Head above hips above feet. + EXPECT_GT(yOf("Head"), yOf("Hips")); + EXPECT_GT(yOf("Hips"), yOf("LeftFoot")); + EXPECT_GT(yOf("Hips"), yOf("RightFoot")); + // Symmetric feet stay on opposite sides of centre (x sign preserved). + EXPECT_GT(yOf("Head"), 1.5); // head lands in the upper portion +} + +TEST(AutoRigCore, FitIsRobustToDegenerateInput) +{ + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Generic); + AutoRig::Options o; + int rc = -1; + // Null / zero-count → returns the template unchanged, no crash, rc=0. + const auto p0 = AutoRig::fitTemplate(tmpl, nullptr, 0, o, &rc); + EXPECT_EQ(p0.size(), tmpl.size()); + EXPECT_EQ(rc, 0); + + // Single degenerate vertex (all same point) → no division blow-up. + std::vector one = {0.5f, 0.5f, 0.5f}; + const auto p1 = AutoRig::fitTemplate(tmpl, one.data(), 1, o, &rc); + EXPECT_EQ(p1.size(), tmpl.size()); + for (const auto& j : p1) + for (int a = 0; a < 3; ++a) + EXPECT_TRUE(std::isfinite(j.pos[a])); +} + +TEST(AutoRigCore, TemplateStringRoundTrip) +{ + using T = AutoRig::Template; + for (auto t : {T::Humanoid, T::Biped, T::Quadruped, T::Generic}) + EXPECT_EQ(AutoRig::templateFromString(AutoRig::templateToString(t)), t); + // Unknown → humanoid default; alias "quad". + EXPECT_EQ(AutoRig::templateFromString("nonsense"), T::Humanoid); + EXPECT_EQ(AutoRig::templateFromString("quad"), T::Quadruped); + EXPECT_EQ(AutoRig::templateFromString("HUMANOID"), T::Humanoid); +} + +TEST(AutoRigCore, ReportSerialization) +{ + AutoRig::Report r; + r.applied = true; + r.meshName = "robot"; + r.templateName = "humanoid"; + r.boneCount = 19; + r.verticesSampled = 1234; + r.jointsRecentered = 11; + const auto j = AutoRig::reportToJson(r); + EXPECT_TRUE(j["applied"].toBool()); + EXPECT_EQ(j["boneCount"].toInt(), 19); + EXPECT_EQ(j["template"].toString(), "humanoid"); + EXPECT_FALSE(AutoRig::reportToText(r).isEmpty()); + + AutoRig::Report fail; + fail.applied = false; + fail.error = "boom"; + EXPECT_TRUE(AutoRig::reportToText(fail).contains("boom")); +} diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index c1511bfa..3517bb63 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -23,6 +23,7 @@ #include "UvUnwrap.h" #include "QuadRetopo.h" #include "SkinWeights.h" +#include "AutoRig.h" #include "MeshDecimator.h" #include "EditableMesh.h" #include "TexturePaintBuffer.h" @@ -1499,6 +1500,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "uv") rc = cmdUv(argc, argv); else if (cmd == "retopo") rc = cmdRetopo(argc, argv); else if (cmd == "skin") rc = cmdSkin(argc, argv); + else if (cmd == "rig") rc = cmdRig(argc, argv); else if (cmd == "morph") rc = cmdMorph(argc, argv); else if (cmd == "nodeanim") rc = cmdNodeAnim(argc, argv); else if (cmd == "cloud") rc = CloudCLIPipeline::run(argc, argv); @@ -8164,6 +8166,123 @@ int CLIPipeline::cmdSkin(int argc, char* argv[]) return 0; } +int CLIPipeline::cmdRig(int argc, char* argv[]) +{ + // Parse: rig [--skeleton humanoid|biped|quadruped|generic] + // [--skin] [--up-axis x|y|z] -o [--json] + QString inputPath, outputPath, templateName = QStringLiteral("humanoid"); + bool jsonOutput = false; + bool alsoSkin = false; + int upAxis = 1; // +Y default + + for (int i = 1; i < argc; ++i) { + const QString arg = QString::fromLocal8Bit(argv[i]); + if (arg == "rig" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--skin") { alsoSkin = true; continue; } + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString::fromLocal8Bit(argv[++i]); continue; + } + if ((arg == "--skeleton" || arg == "--template") && i + 1 < argc) { + templateName = QString::fromLocal8Bit(argv[++i]); continue; + } + if (arg == "--up-axis" && i + 1 < argc) { + const QString a = QString::fromLocal8Bit(argv[++i]).toLower(); + if (a == "x") upAxis = 0; + else if (a == "y") upAxis = 1; + else if (a == "z") upAxis = 2; + else { err() << "Error: --up-axis must be x, y, or z." << Qt::endl; return 2; } + continue; + } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh rig [--skeleton humanoid|biped|quadruped|generic] " + "[--skin] [--up-axis x|y|z] -o [--json]" << Qt::endl; + return 2; + } + if (outputPath.isEmpty()) { + err() << "Error: -o required." << Qt::endl; + return 2; + } + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: file not found: " << inputPath << Qt::endl; return 1; + } + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QString("rig .%1 template=%2 skin=%3") + .arg(fi.suffix(), templateName).arg(alsoSkin)); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + QList meshEntities; + for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) { + if (e && e->getMovableType() == "Entity") + meshEntities.push_back(e); + } + if (meshEntities.isEmpty()) { + err() << "Error: failed to load " << inputPath << Qt::endl; return 1; + } + if (meshEntities.size() > 1) { + err() << "Error: " << inputPath + << " contains multiple mesh entities. `qtmesh rig` supports one " + "entity per file." << Qt::endl; + return 1; + } + Ogre::Entity* entity = meshEntities.first(); + + AutoRig::Options opts; + opts.tmpl = AutoRig::templateFromString(templateName); + opts.upAxis = upAxis; + + AutoRig::Report report = AutoRig::rigEntity(entity, opts); + if (!report.applied) { + err() << "Error: auto-rig failed — " << report.error << Qt::endl; + return 1; + } + + // Optionally chain skin weights so the exported asset deforms. + bool skinned = false; + if (alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) { + err() << "Error: rigged, but skinning failed — " << sw.error << Qt::endl; + return 1; + } + } + + auto* node = entity->getParentSceneNode(); + const QString fmt = formatForExtension(outputPath); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QString("Exporting %1").arg(QFileInfo(outputPath).absoluteFilePath())); + if (MeshImporterExporter::exporter(node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { + err() << "Error: export failed." << Qt::endl; + return 1; + } + + if (jsonOutput) { + QJsonObject j = AutoRig::reportToJson(report); + j["skinned"] = skinned; + cliWrite(QString::fromUtf8( + QJsonDocument(j).toJson(QJsonDocument::Indented)) + "\n"); + } else { + cliWrite(AutoRig::reportToText(report) + + (alsoSkin ? QString(" skinned: %1\n").arg(skinned ? "yes" : "no") + : QString()) + + QString("Wrote: %1\n").arg(QFileInfo(outputPath).fileName())); + } + return 0; +} + int CLIPipeline::cmdMorph(int argc, char* argv[]) { // Parse: morph --list [--json] diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 4a01e287..7a2eee40 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -207,6 +207,11 @@ class CLIPipeline { /// distance heuristic. Issue #402. static int cmdSkin(int argc, char* argv[]); + /// Native auto-rig: embed a skeleton template (humanoid / biped / + /// quadruped / generic) into an unrigged mesh, optionally chain + /// skin weights (--skin), and export. Issue #407. + static int cmdRig(int argc, char* argv[]); + /// List the morph targets / blend shapes on a mesh file. Slice A1 /// surfaces a `--list` mode only; subsequent slices add `--set`, /// `--add`, `--delete` once the in-memory authoring path lands. diff --git a/src/CLIPipeline_cmdrig_coverage_test.cpp b/src/CLIPipeline_cmdrig_coverage_test.cpp new file mode 100644 index 00000000..85344a99 --- /dev/null +++ b/src/CLIPipeline_cmdrig_coverage_test.cpp @@ -0,0 +1,138 @@ +// Coverage tests for CLIPipeline::cmdRig (#407, auto-rig). Mirrors the +// cmdSkin coverage style: the argument-validation branches (return 2) and the +// file-not-found branch (return 1) need no GL context, so they exercise the +// parser without a loaded mesh. The full rig+export path needs a real mesh and +// is exercised under Xvfb on CI via the success-path test below (which is +// skipped gracefully when Ogre can't init). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CLIPipeline.h" +#include "TestHelpers.h" + +namespace { + +// RAII argc/argv builder, own anon-namespace name (no ODR clash). +class RigArgv { +public: + RigArgv(std::initializer_list args) + { + for (auto* a : args) m_storage.push_back(QByteArray(a)); + for (auto& ba : m_storage) m_argv.push_back(ba.data()); + m_argc = static_cast(m_argv.size()); + } + int argc() const { return m_argc; } + char** argv() { return m_argv.data(); } +private: + QList m_storage; + QList m_argv; + int m_argc = 0; +}; + +const char* kMissingFile = "/nonexistent_qtmesh_rig_input_zzz.obj"; + +} // namespace + +// ── Required-argument checks (return 2) ───────────────────────────────────── + +TEST(CLIPipelineCmdRigCoverageError, NoInputFile) +{ + RigArgv args({"rig"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, NoInputButFlags) +{ + RigArgv args({"rig", "--json", "--skin"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, InputButNoOutput) +{ + RigArgv args({"rig", kMissingFile}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, BadUpAxisIsUsageError) +{ + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--up-axis", "w"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +// ── File-existence branch (return 1) ──────────────────────────────────────── + +TEST(CLIPipelineCmdRigCoverageError, MissingFileWithValidArgs) +{ + // Valid template + output, but the input doesn't exist -> 1. + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--skeleton", "humanoid"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdRigCoverageError, UnknownTemplateStillParsesThenFileMissing) +{ + // An unrecognised template name is tolerated by templateFromString + // (falls back to humanoid), so it must NOT be a usage error (2); + // it proceeds to the file-existence check -> 1. + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--skeleton", "dragon"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdRigCoverageError, EveryValidUpAxisParses) +{ + for (const char* ax : {"x", "y", "z"}) { + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--up-axis", ax}); + // Valid axis -> passes parse, then file-not-found -> 1 (never 2). + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1) + << "up-axis " << ax << " should parse"; + } +} + +// ── Success path (needs a GL/Ogre context; skipped without one) ───────────── + +TEST(CLIPipelineCmdRigSuccess, RigsStaticMeshAndExports) +{ + if (!tryInitOgre() || !canLoadMeshFiles()) + GTEST_SKIP() << "Ogre/GL unavailable (needs Xvfb)."; + + // Build a static (skeleton-less) mesh on disk by exporting a simple + // in-memory triangle mesh to OBJ — OBJ carries no skeleton. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // Reuse the editor's own loader path: write a minimal OBJ cube-ish quad. + const QString objPath = dir.filePath("static.obj"); + { + QFile f(objPath); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + // A small upright pyramid-ish shape (8 verts spanning a 1x2x1 box). + const char* obj = + "v -0.4 0 -0.4\nv 0.4 0 -0.4\nv 0.4 0 0.4\nv -0.4 0 0.4\n" + "v -0.2 2 -0.2\nv 0.2 2 -0.2\nv 0.2 2 0.2\nv -0.2 2 0.2\n" + "f 1 2 3\nf 1 3 4\nf 5 6 7\nf 5 7 8\n" + "f 1 2 6\nf 1 6 5\nf 3 4 8\nf 3 8 7\n"; + f.write(obj); + f.close(); + } + + const QString outPath = dir.filePath("rigged.gltf"); + // Hold the path bytes in stable std::strings so the argv char* stay valid. + const std::string objStr = objPath.toStdString(); + const std::string outStr = outPath.toStdString(); + RigArgv args({"rig", objStr.c_str(), "-o", outStr.c_str(), + "--skeleton", "humanoid"}); + const int rc = CLIPipeline::cmdRig(args.argc(), args.argv()); + // Either it rigs+exports (0) or the OBJ import path isn't available in this + // headless build (1) — but it must never crash or return a usage error. + EXPECT_NE(rc, 2); + if (rc == 0) + EXPECT_TRUE(QFile::exists(outPath)) << "rigged mesh should be written"; +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3d5646a0..835eea08 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -90,6 +90,8 @@ QuadRetopo.cpp QuadRetopoController.cpp SkinWeights.cpp SkinWeightsController.cpp +AutoRig.cpp +AutoRigController.cpp MeshDepthRenderer.cpp MultiViewTextureBaker.cpp TextureChannelPacker.cpp @@ -229,6 +231,8 @@ QuadRetopo.h QuadRetopoController.h SkinWeights.h SkinWeightsController.h +AutoRig.h +AutoRigController.h MeshDepthRenderer.h MultiViewTextureBaker.h TextureChannelPacker.h diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 5d8c8817..77db11c4 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -39,6 +39,7 @@ #include "ScanEngine.h" #include "QuadRetopo.h" #include "SkinWeights.h" +#include "AutoRig.h" #include "MeshDepthRenderer.h" #include "ModelIsometricRenderer.h" #ifdef ENABLE_STABLE_DIFFUSION @@ -576,6 +577,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("auto_uv_unwrap"), &MCPServer::toolAutoUvUnwrap}, {QStringLiteral("retopologize"), &MCPServer::toolRetopologize}, {QStringLiteral("compute_skin_weights"), &MCPServer::toolComputeSkinWeights}, + {QStringLiteral("auto_rig"), &MCPServer::toolAutoRig}, {QStringLiteral("generate_mesh_texture"), &MCPServer::toolGenerateMeshTexture}, {QStringLiteral("generate_pbr_maps"), &MCPServer::toolGeneratePbrMaps}, {QStringLiteral("upscale_texture"), &MCPServer::toolUpscaleTexture}, @@ -1664,6 +1666,106 @@ QJsonObject MCPServer::toolComputeSkinWeights(const QJsonObject &args) return result; } +QJsonObject MCPServer::toolAutoRig(const QJsonObject &args) +{ + // Issue #407: native auto-rig of the selected STATIC mesh. Generates a + // skeleton from a template, binds it, optionally chains skin weights, and + // optionally re-exports. + if (!hasSelectedEntities()) + return makeErrorResult("No mesh selected. Load a mesh first with load_mesh."); + + if (args.contains("skin") && !args["skin"].isBool()) + return makeErrorResult("Error: 'skin' must be a boolean."); + + AutoRig::Options opts; + if (args.contains("template")) { + if (!args["template"].isString()) + return makeErrorResult("Error: 'template' must be a string."); + opts.tmpl = AutoRig::templateFromString(args["template"].toString()); + } + if (args.contains("up_axis")) { + const QString a = args["up_axis"].toString().toLower(); + if (a == "x") opts.upAxis = 0; + else if (a == "y") opts.upAxis = 1; + else if (a == "z") opts.upAxis = 2; + else return makeErrorResult("Error: 'up_axis' must be 'x', 'y', or 'z'."); + } + const bool alsoSkin = args.value("skin").toBool(false); + + SelectionSet* sel = SelectionSet::getSingleton(); + const QList resolved = sel ? sel->getResolvedEntities() + : QList{}; + if (resolved.isEmpty()) + return makeErrorResult("No selected entity."); + Ogre::Entity* entity = resolved.first(); + if (!entity) return makeErrorResult("Selected entity is null."); + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("auto_rig entity=%1 template=%2 skin=%3") + .arg(QString::fromStdString(entity->getName()), + AutoRig::templateToString(opts.tmpl)) + .arg(alsoSkin)); + + // Validate output_path type up front (like 'skin'/'template') — a + // non-string would otherwise coerce to "" and silently skip the export + // while still reporting success. + if (args.contains("output_path") && !args["output_path"].isString()) + return makeErrorResult("Error: 'output_path' must be a string."); + const QString outputPath = args.value("output_path").toString(); + + AutoRig::Report report; + bool skinned = false; + // Wrap the full mutating + export section so export failures and + // std::runtime_error (not just Ogre::Exception) reach the MCP error path. + try { + report = AutoRig::rigEntity(entity, opts); + if (!report.applied) + return makeErrorResult( + QStringLiteral("Auto-rig failed: %1").arg(report.error)); + + if (alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + // A requested skin that failed is a hard error — don't export an + // unskinned asset and report success. + if (!sw.applied) + return makeErrorResult(QStringLiteral( + "Auto-rig succeeded, but the requested skinning failed: %1") + .arg(sw.error)); + } + + // Optional re-export of the now-rigged mesh. + if (!outputPath.isEmpty()) { + Ogre::SceneNode* node = entity->getParentSceneNode(); + if (!node) + return makeErrorResult( + QStringLiteral("Error: rigged, but the entity has no scene " + "node to export from")); + // Don't leak the full local path (usernames / private dirs) to Sentry. + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("auto_rig export requested")); + const int rc = MeshImporterExporter::exporter( + node, outputPath, CLIPipeline::formatForExtension(outputPath)); + if (rc != 0) + return makeErrorResult( + QStringLiteral("Error: rigged but export to '%1' failed (code %2)") + .arg(outputPath).arg(rc)); + } + } catch (const Ogre::Exception& e) { + return makeErrorResult(QStringLiteral("Ogre error: %1") + .arg(QString::fromStdString(e.getFullDescription()))); + } catch (const std::exception& e) { + return makeErrorResult(QStringLiteral("Auto-rig error: %1") + .arg(QString::fromUtf8(e.what()))); + } + + QJsonObject result = makeSuccessResult(AutoRig::reportToText(report)); + QJsonObject j = AutoRig::reportToJson(report); + j["skinned"] = skinned; + result["rig"] = j; + return result; +} + QJsonObject MCPServer::toolGenerateMeshTexture(const QJsonObject &args) { #ifndef ENABLE_STABLE_DIFFUSION @@ -6365,6 +6467,35 @@ QJsonArray MCPServer::buildToolsList() ); } + // auto_rig (#407) + { + QJsonObject props; + props["template"] = QJsonObject{{"type", "string"}, + {"description", + "Skeleton template: 'humanoid' (19-bone, default), 'biped', " + "'quadruped', or 'generic' (3-joint spine fallback)."}}; + props["skin"] = QJsonObject{{"type", "boolean"}, + {"description", + "When true, also compute + apply skin weights so the mesh deforms " + "immediately (chains compute_skin_weights). Default false."}}; + props["up_axis"] = QJsonObject{{"type", "string"}, + {"description", "Mesh up axis: 'x', 'y' (default), or 'z'."}}; + props["output_path"] = QJsonObject{{"type", "string"}, + {"description", + "Optional path to re-export the rigged mesh. When omitted, the rig is " + "applied to the in-session scene only."}}; + appendTool( + "auto_rig", + "Auto-rig the currently selected STATIC (unrigged) mesh by embedding a " + "skeleton template into it (issue #407). Native heuristic (no external " + "deps): maps a proportional joint graph into the mesh AABB and recentres " + "joints toward the mesh's medial mass. Best on roughly upright, manifold, " + "T/A-pose meshes with +Y up. Already-skinned meshes are rejected. Pair " + "skin:true for a one-click rig+skin.", + props + ); + } + // generate_mesh_texture — only advertised when Stable Diffusion is // compiled in; the handler hard-fails otherwise, so publishing it on // a non-SD build would imply a capability the server can't satisfy. diff --git a/src/MCPServer.h b/src/MCPServer.h index e47413af..b4faa8ff 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -157,6 +157,9 @@ private slots: /// Issue #402: compute skin weights via inverse-distance /// heuristic. Mesh must have a skeleton attached. QJsonObject toolComputeSkinWeights(const QJsonObject &args); + /// #407: native auto-rig of the selected static mesh (template embedding), + /// optional skin chain + re-export. + QJsonObject toolAutoRig(const QJsonObject &args); /// Issue #403: mesh-aware (depth-conditioned) texture /// generation. Renders the selected entity's depth map and /// conditions sd.cpp on it via a ControlNet depth model, then diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 05f767f8..da8f9946 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -101,6 +101,7 @@ #include "UvUnwrapController.h" #include "QuadRetopoController.h" #include "SkinWeightsController.h" +#include "AutoRigController.h" #include "MeshDepthRenderer.h" #include "MaterialPresetLibrary.h" #include "MaterialPreviewRenderer.h" @@ -649,6 +650,11 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return SkinWeightsController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "AutoRigController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return AutoRigController::qmlInstance(engine, nullptr); + }); #ifdef ENABLE_AUTO_UPDATER qmlRegisterSingletonType( "Updater", 1, 0, "UpdaterController", diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 420dfee9..1f99f532 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -282,6 +282,13 @@ TEST_F(MainWindowTest, ModeBarLoadsAndModeChangeUpdatesStatusIndicator) ASSERT_EQ(window->m_modeBar->status(), QQuickWidget::Ready); EXPECT_GE(window->m_modeBar->minimumWidth(), 560); EXPECT_EQ(window->toolBarArea(window->m_modeBarShell), Qt::TopToolBarArea); + // QToolBar::isHidden() reflects effective visibility, which is only + // meaningful once the parent window has been shown. The fixture constructs + // MainWindow without show()ing it, so under Xvfb this assertion was flaky + // (the shell reports hidden until the window is mapped). Show the window and + // drain events so the toolbar's visibility is realized before asserting. + window->show(); + app->processEvents(); EXPECT_FALSE(window->m_modeBarShell->isHidden()); ASSERT_NE(window->m_editModeLabel, nullptr); diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index e03fc373..a4b713ec 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -12,6 +12,7 @@ ../qml/UvUnwrapDialog.qml ../qml/QuadRetopoDialog.qml ../qml/SkinWeightsDialog.qml + ../qml/AutoRigDialog.qml ../qml/IsometricSpritesDialog.qml ../qml/qmldir ../qml/ThemedButton.qml diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2778fdb6..cd3a57df 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -137,6 +137,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/QuadRetopoController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeights.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeightsController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AutoRig.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AutoRigController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshDepthRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshOptimizerLod.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ExportOptimizer.cpp