diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c2c918c61..e6634d446 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,11 +23,16 @@ 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; + # sdkpin2 = bust the stale assimp cache that still baked the Xcode 26.5 + # libz.tbd path — "No rule to make target .../MacOSX26.5.sdk/.../libz.tbd" + # when OGRE consumed it under the pinned 26.3.) + MACOS_CACHE_VERSION: 'sdkpin2' jobs: # send-slack-notification: @@ -1580,6 +1585,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 +1624,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 +1657,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 +1705,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 +1738,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 +1817,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 501739cf3..0d1bd4679 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. **Mixamo-style marker placement** (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — `AutoRig::humanoidMarkerOrder()`), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is `AutoRig::fitTemplateWithMarkers` and it does **coherent inference**, not per-marker patching: it runs `fitTemplate` for a proportional baseline (and to read the template's segment vectors / lateral offsets), then **resolves an anchor for every key joint** (Head, L/R Shoulder, L/R Hand, Hips, L/R UpLeg, L/R Knee) as *marked → inferred-from-marked-neighbours → template* and lays the dependent chains (spine, arms, legs) from those anchors — so a partial marker set yields an anatomically-sane skeleton instead of mixing marked anchors with stranded template joints (no shoulder-above-head). Inference: Hips ← midpoint of marked up-legs + template socket→pelvis rise; Head ← template offset above resolved Hips; UpLeg ← mirror the other up-leg across the pelvis, else pelvis + template socket offset; Shoulder ← along the live Hips→Head line at the template shoulder-height fraction + template lateral offset, else mirror the other; Hand ← shoulder + template arm vector (marked shoulder + skipped wrist still lays a full arm); Knee/foot ← clamped to the mesh AABB so an inferred leg never punches through the model: when the knee is skipped, the foot is dropped straight to the mesh FLOOR (`mn[up]`) below the up-leg and the knee placed halfway between (template thigh-vector extrapolation, which used to shoot feet past the lower limit, is only used for the small forward knee nudge); a marked knee keeps its position with the foot extrapolated below but still floor-clamped. Mirroring reflects across the sagittal plane (side axis auto-detected). An empty marker set early-returns `fitTemplate` unchanged; `report.markersApplied` counts only user-placed markers. (Legacy per-marker description retained below for the chain mechanics.) The old behaviour was: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→`layChain` lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→`layLeg` anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). `layChain` is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (`report.markersApplied` counts the placed ones; an empty marker set is bit-identical to `fitTemplate`). The viewport flow lives in `AutoRigController` (marker-session state machine: `beginMarkerPlacement`/`skipCurrentMarker`/`undoLastMarker`/`cancelMarkerPlacement`/`commitMarkerRig`); clicks are routed in by `TransformOperator::mousePressEvent` (checked **before** the knife/select paths when `markerMode()` is true), ray-cast to the mesh surface (`getCameraToViewportRay` → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow `PT_SPHERE` overlays. **The whole UI is inline in the Inspector's "Rigging" section** (`riggingToolsComponent` in `qml/PropertiesPanel.qml`) — there is no separate dialog (the old `AutoRigDialog.qml` was removed). It show/hides smartly: idle shows the two entry points ("Place markers…" / "Auto-Rig (template)"), a skin checkbox, and an "Advanced options" checkbox that reveals the template + up-axis pickers; while `markerMode` is active it swaps to the per-marker guidance label + Skip/Undo/Cancel/"Rig from markers" controls. Rig state + the `runAutoRig`/`runMarkerRig` helpers live on the `PropertiesPanel` root; the section's `onSectionVisibleChanged` cancels any active marker session if the section disappears (mode change / deselect / re-rig), replacing the dialog's old `onClosing` cancel. No CLI/MCP marker surface — guided placement is inherently interactive. - **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/CMakeLists.txt b/CMakeLists.txt index 8c8434cad..8ef04fe4b 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 3.9.0 LANGUAGES C CXX) +project(QtMeshEditor VERSION 3.9.2 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/README.md b/README.md index 70e5bba28..02325cf51 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act **Versioning** - **Always follow the latest GitHub release** — use the Marketplace floating tag `fernandotonon/QtMeshEditor@v1` (same pattern as the [Marketplace example](https://github.com/marketplace/actions/qtmesheditor)). The composite action defaults to `image-tag: latest`, so the Docker CLI tracks the newest published `ghcr.io/fernandotonon/qtmesh` image. -- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.9.0**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. +- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.9.2**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. Pinned workflow template (action + `ghcr.io` image aligned): @@ -53,10 +53,10 @@ jobs: - uses: actions/checkout@v4 - name: Run QtMesh scan - uses: fernandotonon/QtMeshEditor@3.9.0 + uses: fernandotonon/QtMeshEditor@3.9.2 with: command: scan - image-tag: "3.9.0" + image-tag: "3.9.2" env: QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }} ``` @@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/ ```yaml # Validate a specific mesh -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: validate input-file: ./models/character.fbx - image-tag: "3.9.0" + image-tag: "3.9.2" # Convert FBX → glTF -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: convert input-file: ./models/character.fbx output-file: ./output/character.gltf2 - image-tag: "3.9.0" + image-tag: "3.9.2" # Resample Mixamo animations (200+ keyframes → 30) -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: anim input-file: ./animations/dance.fbx output-file: ./output/dance_optimized.fbx options: --resample 30 - image-tag: "3.9.0" + image-tag: "3.9.2" # Get mesh info as JSON -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.2 id: info with: command: info input-file: ./models/character.fbx options: --json - image-tag: "3.9.0" + image-tag: "3.9.2" # Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref) docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 1b94886c1..f4e06acf3 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -20,6 +20,154 @@ Rectangle { property bool showAllModeTools: false property var bottomToolHost: null + // ---- Auto-rig (#407) inline state, lives in the Inspector Rigging section + // (replaces the old modal AutoRigDialog) ---- + property var rigTemplates: ["humanoid", "biped", "quadruped", "generic"] + property int rigTemplateIndex: 0 + property var rigUpAxes: ["x", "y", "z"] + property int rigUpAxisIndex: 1 // +Y default + property bool rigAlsoSkin: true + property bool rigShowAdvanced: false // template / up-axis pickers + property string rigStatus: "" + property bool rigStatusError: false + + function runAutoRig() { + if (AutoRigController.busy || !AutoRigController.hasRiggableSelection) return + const r = AutoRigController.autoRigSelected( + root.rigTemplates[root.rigTemplateIndex], + root.rigUpAxes[root.rigUpAxisIndex], + root.rigAlsoSkin) + if (r && r.applied) { + root.rigStatus = "Rigged: " + r.boneCount + " bones, " + + r.verticesSampled + " verts, " + + r.jointsRecentered + " recentered" + + (root.rigAlsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + root.rigStatusError = false + } else { + root.rigStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + root.rigStatusError = true + } + } + + function runMarkerRig() { + if (AutoRigController.busy) return + const r = AutoRigController.commitMarkerRig(root.rigAlsoSkin) + if (r && r.applied) { + root.rigStatus = "Rigged from markers: " + r.boneCount + " bones, " + + r.markersApplied + " markers" + + (root.rigAlsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + root.rigStatusError = false + } else { + root.rigStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + root.rigStatusError = true + } + } + + Connections { + target: AutoRigController + function onError(msg) { + root.rigStatus = "Failed: " + msg + root.rigStatusError = true + } + } + + // ---- Small inline Inspector primitives reused by the Rigging section ---- + component RigButton: Rectangle { + id: rb + property string label: "" + property bool buttonEnabled: true + signal clicked() + implicitWidth: rbText.implicitWidth + 18 + height: 24 + radius: 3 + opacity: rb.buttonEnabled ? 1.0 : 0.45 + color: rbMa.containsMouse && rb.buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: rbText + anchors.centerIn: parent + text: rb.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: rbMa + anchors.fill: parent + hoverEnabled: true + enabled: rb.buttonEnabled + cursorShape: rb.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: rb.clicked() + } + } + + component RigCheckbox: Row { + id: rcb + property string label: "" + property bool checked: false + signal toggled() + spacing: 6 + Rectangle { + width: 14; height: 14; radius: 2 + anchors.verticalCenter: parent.verticalCenter + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: rcb.checked ? "✓" : "" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: rcb.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: rcb.toggled() + } + } + + component RigSegments: Row { + id: rseg + property var options: [] + property int index: 0 + signal picked(int i) + spacing: 4 + Repeater { + model: rseg.options + Rectangle { + width: Math.max(56, rsegText.implicitWidth + 16) + height: 22 + radius: 3 + color: index === rseg.index + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: rsegText + anchors.centerIn: parent + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: rseg.picked(index) + } + } + } + } + function revealBottomTool(toolId) { if (bottomToolHost && bottomToolHost.revealBottomTool) bottomToolHost.revealBottomTool(toolId) @@ -331,6 +479,50 @@ 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 { + id: riggingSection + title: "Rigging" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && AutoRigController.hasRiggableSelection + expanded: false + + Component.onCompleted: content = riggingToolsComponent + + // Don't strand the viewport in marker-capture mode if the + // section disappears (mode change, deselect, re-rig) — the + // inline UI replaced the dialog's onClosing cancel. + onSectionVisibleChanged: if (!sectionVisible + && AutoRigController.markerMode) + AutoRigController.cancelMarkerPlacement() + } + + // ---- Skeleton (Animation mode) ---- + // Bone/skeleton visualization toggles (skeleton overlay + bone-weight + // heat-map). Lives in its OWN section, independent of animation clips, + // so it surfaces for ANY skinned mesh — including a skeleton-bearing + // mesh with no animations yet (e.g. a freshly auto-rigged static + // mesh). Previously these toggles were buried per-animation-group + // inside the Animations section and never appeared without clips. + // This is the home for future bone-level features (bone select, + // per-bone transforms, etc.). + CollapsibleSection { + title: "Skeleton" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && PropertiesPanelController.hasSkeletonSelection + expanded: false + + Component.onCompleted: content = skeletonToolsComponent + } + // ---- Texture Paint (Material mode) ---- // (Brush color/radius/strength/falloff live on the toolbar // paint-brush popup. The Inspector panel keeps only the @@ -1267,6 +1459,263 @@ Rectangle { } } + // ---- Rigging Tools Content (Animation mode) ---- + // Issue #407: native auto-rig, inline in the Inspector (no modal dialog). + // Smart show/hide: + // * marker mode active → only the guidance + in-session controls show, + // * idle → the two entry points (markers / template), + // skin checkbox, and a collapsible "Advanced" + // block (template + up-axis pickers). + // All gated on AutoRigController.hasRiggableSelection (a static mesh). + Component { + id: riggingToolsComponent + + Column { + id: rigCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 8 + + readonly property bool canRig: AutoRigController.hasRiggableSelection + readonly property bool marking: AutoRigController.markerMode + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + opacity: 0.8 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: rigCol.marking + ? "Click each highlighted point on the mesh in the viewport." + : (rigCol.canRig + ? "Embed a skeleton into this unrigged mesh. Use markers for a " + + "better fit (Mixamo-style), or a plain template. Optionally " + + "skin in one click." + : "Select a static (unrigged) mesh to enable rigging.") + } + + // ── Marker mode: guidance + in-session controls only ────────── + Column { + width: parent.width - 16 + spacing: 6 + visible: rigCol.marking + + Text { + width: parent.width + wrapMode: Text.Wrap + font.pixelSize: 11 + color: PropertiesPanelController.highlightColor + text: AutoRigController.currentMarkerLabel.length > 0 + ? ("Place: " + AutoRigController.currentMarkerLabel + + " (" + AutoRigController.markerCount + "/" + + AutoRigController.markerTotal + ")") + : ("All " + AutoRigController.markerCount + "/" + + AutoRigController.markerTotal + + " placed — click 'Rig from markers'") + } + + Flow { + width: parent.width + spacing: 6 + RigButton { + label: "Skip" + buttonEnabled: AutoRigController.currentMarkerLabel.length > 0 + onClicked: AutoRigController.skipCurrentMarker() + } + RigButton { + label: "Undo" + buttonEnabled: AutoRigController.markerCount > 0 + onClicked: AutoRigController.undoLastMarker() + } + RigButton { + label: "Cancel" + onClicked: AutoRigController.cancelMarkerPlacement() + } + RigButton { + label: AutoRigController.busy ? "Rigging…" : "Rig from markers" + buttonEnabled: !AutoRigController.busy + && AutoRigController.markerPlacedCount > 0 + onClicked: root.runMarkerRig() + } + } + } + + // ── Idle: skeleton type + entry points + options ────────────── + Column { + width: parent.width - 16 + spacing: 8 + visible: !rigCol.marking + + // Skeleton type — a primary choice, always visible. + Text { + text: "Skeleton type" + color: PropertiesPanelController.textColor + opacity: 0.8 + font.pixelSize: 10 + } + Flow { + width: parent.width + spacing: 4 + RigSegments { + options: root.rigTemplates + index: root.rigTemplateIndex + onPicked: function(i) { root.rigTemplateIndex = i } + } + } + + Flow { + width: parent.width + spacing: 6 + RigButton { + // Markers are a humanoid concept (chin/shoulders/wrists/ + // hips/knees) — only offered for the humanoid template. + label: "Place markers…" + buttonEnabled: rigCol.canRig && !AutoRigController.busy + && root.rigTemplates[root.rigTemplateIndex] === "humanoid" + onClicked: AutoRigController.beginMarkerPlacement( + root.rigUpAxes[root.rigUpAxisIndex]) + } + RigButton { + label: AutoRigController.busy ? "Rigging…" : "Auto-Rig (template)" + buttonEnabled: rigCol.canRig && !AutoRigController.busy + onClicked: root.runAutoRig() + } + } + + RigCheckbox { + label: "Also compute skin weights" + checked: root.rigAlsoSkin + onToggled: root.rigAlsoSkin = !root.rigAlsoSkin + } + + // Advanced options toggle (just the up-axis picker for now). + RigCheckbox { + label: "Advanced options" + checked: root.rigShowAdvanced + onToggled: root.rigShowAdvanced = !root.rigShowAdvanced + } + + Column { + width: parent.width + spacing: 6 + visible: root.rigShowAdvanced + + Text { + text: "Up axis (+Y is the in-app default)" + color: PropertiesPanelController.textColor + opacity: 0.8 + font.pixelSize: 10 + } + RigSegments { + options: root.rigUpAxes + index: root.rigUpAxisIndex + onPicked: function(i) { root.rigUpAxisIndex = i } + } + } + } + + // ── Status line (both modes) ────────────────────────────────── + Text { + width: parent.width - 16 + visible: root.rigStatus.length > 0 + wrapMode: Text.Wrap + font.pixelSize: 10 + text: root.rigStatus + color: root.rigStatusError ? "#cc4444" : "#3a8c3a" + } + } + } + + // ---- Skeleton Tools Content (Animation mode) ---- + // Per-entity skeleton/bone visualization toggles, sourced from + // PropertiesPanelController.skeletonData() (skeleton-bearing entities, + // independent of animation clips). Refreshes on selectionChanged / + // animationStateChanged so a just-auto-rigged mesh shows up immediately. + Component { + id: skeletonToolsComponent + + Column { + id: skeletonToolsCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 8 + + property var skelGroups: PropertiesPanelController.skeletonData() + Connections { + target: PropertiesPanelController + function onAnimationStateChanged() { + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + function onSelectionChanged() { + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + } + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + opacity: 0.8 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: "Visualize the skeleton and per-vertex bone weights for the " + + "selected skinned mesh." + } + + Repeater { + model: skeletonToolsCol.skelGroups + delegate: Column { + required property var modelData + width: skeletonToolsCol.width - 16 + spacing: 4 + + // Entity name (only worth showing when multiple are selected). + Text { + visible: skeletonToolsCol.skelGroups.length > 1 + text: modelData.entity + color: PropertiesPanelController.textColor + opacity: 0.7 + font.pixelSize: 10 + } + + Row { + spacing: 8 + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: modelData.showSkeleton ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor + Text { anchors.centerIn: parent; text: modelData.showSkeleton ? "✓" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + PropertiesPanelController.toggleSkeletonDebug(modelData.entity, !modelData.showSkeleton) + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + } + } + Text { text: "Skeleton"; color: PropertiesPanelController.textColor; font.pixelSize: 11; anchors.verticalCenter: parent.verticalCenter } + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: modelData.showWeights ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor + Text { anchors.centerIn: parent; text: modelData.showWeights ? "✓" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + PropertiesPanelController.toggleBoneWeights(modelData.entity, !modelData.showWeights) + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + } + } + Text { text: "Weights"; color: PropertiesPanelController.textColor; font.pixelSize: 11; anchors.verticalCenter: parent.verticalCenter } + } + } + } + } + } + // ---- Edit Mode Tools Content ---- Component { id: editModeToolsComponent @@ -4104,6 +4553,10 @@ Rectangle { } } + // Issue #407: native auto-rig now lives inline in the Inspector Rigging + // section (riggingToolsComponent) — no modal dialog. The old AutoRigDialog + // Loader / openAutoRigDialog() were removed. + Loader { id: isometricSpritesLoader active: false @@ -5221,29 +5674,9 @@ Rectangle { } } - // Skeleton/Weights row (if has skeleton) - Row { - visible: grp.hasSkeleton - spacing: 8; topPadding: 4 - - Rectangle { - width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter - border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 - color: grp.showSkeleton ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor - Text { anchors.centerIn: parent; text: grp.showSkeleton ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } - MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleSkeletonDebug(grp.entity, !grp.showSkeleton) } - } - Text { text: "Skeleton"; color: PropertiesPanelController.textColor; font.pixelSize: 10; anchors.verticalCenter: parent.verticalCenter } - - Rectangle { - width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter - border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 - color: grp.showWeights ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor - Text { anchors.centerIn: parent; text: grp.showWeights ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } - MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleBoneWeights(grp.entity, !grp.showWeights) } - } - Text { text: "Weights"; color: PropertiesPanelController.textColor; font.pixelSize: 10; anchors.verticalCenter: parent.verticalCenter } - } + // (Skeleton / Weights viz toggles moved to the dedicated + // "Skeleton" section so they surface for skinned meshes + // regardless of whether they have animation clips.) // Export Pose button (if has skeleton) Rectangle { diff --git a/src/AppLaunchHandler.cpp b/src/AppLaunchHandler.cpp index b38a02926..b9c3e8be8 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 000000000..7519218d1 --- /dev/null +++ b/src/AutoRig.cpp @@ -0,0 +1,763 @@ +#include "AutoRig.h" + +#include +#include +#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; +} + +QString AutoRig::markerLabel(MarkerId id) +{ + switch (id) { + case MarkerId::Chin: return QStringLiteral("Chin"); + case MarkerId::LeftShoulder: return QStringLiteral("Left shoulder"); + case MarkerId::RightShoulder: return QStringLiteral("Right shoulder"); + case MarkerId::LeftWrist: return QStringLiteral("Left wrist"); + case MarkerId::RightWrist: return QStringLiteral("Right wrist"); + case MarkerId::LeftUpLeg: return QStringLiteral("Left hip"); + case MarkerId::RightUpLeg: return QStringLiteral("Right hip"); + case MarkerId::LeftKnee: return QStringLiteral("Left knee"); + case MarkerId::RightKnee: return QStringLiteral("Right knee"); + case MarkerId::Hips: return QStringLiteral("Hips"); + case MarkerId::Count: break; + } + return QStringLiteral("?"); +} + +std::vector AutoRig::humanoidMarkerOrder() +{ + // Order = top-down, then limbs: chin, both shoulders, both wrists, both + // hips (thigh roots), both knees, pelvis. Shoulders precede wrists, and the + // hip sockets precede knees, so each limb chain has its attach point placed + // before its tip. Pelvis (Hips) last so it can carry any unmarked thigh + // roots along without overriding ones the user pinned. + return { MarkerId::Chin, + MarkerId::LeftShoulder, MarkerId::RightShoulder, + MarkerId::LeftWrist, MarkerId::RightWrist, + MarkerId::LeftUpLeg, MarkerId::RightUpLeg, + MarkerId::LeftKnee, MarkerId::RightKnee, + MarkerId::Hips }; +} + +namespace { + +// Find a placed joint by name; returns nullptr if absent. +AutoRig::Joint* findJoint(std::vector& js, const char* name) +{ + for (auto& j : js) + if (j.name == QLatin1String(name)) return &j; + return nullptr; +} + +// Place `mid` between `a` and `b` at parameter t (0=a, 1=b). +std::array lerp3(const std::array& a, + const std::array& b, double t) +{ + return { a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t }; +} + +// Lay an N-joint limb chain straight along anchor→marker. `names` is the +// chain in parent→child order; the FIRST joint (the anchor — e.g. the +// shoulder) keeps its template position, the LAST goes to the marker, and +// every joint in between is distributed evenly by index (a straight rest-pose +// limb). Distributing ALL the intermediate joints — not just one midpoint — +// is what makes the whole limb reach toward the marker; anchoring only the +// tip + a single mid leaves the upper segment tucked at its template position. +// Any named joint that's missing is skipped (the rest still lay out from the +// surviving anchor/tip). +void layChain(std::vector& js, + std::initializer_list names, + const std::array& tipMarker) +{ + if (names.size() < 2) return; + AutoRig::Joint* anchor = findJoint(js, *names.begin()); + if (!anchor) return; + const auto a = anchor->pos; // copy: stays put, drives the lerp + const int last = static_cast(names.size()) - 1; + int i = 0; + for (const char* n : names) { + if (i > 0) { // i==0 is the anchor; leave it + if (auto* j = findJoint(js, n)) + j->pos = lerp3(a, tipMarker, static_cast(i) / last); + } + ++i; + } +} + +// Find a placed joint's position by name; returns `fallback` if absent. +std::array jointPosOr(const std::vector& js, + const char* name, + const std::array& fallback) +{ + for (const auto& j : js) if (j.name == QLatin1String(name)) return j.pos; + return fallback; +} + +std::array add3(const std::array& a, const std::array& b) +{ return { a[0]+b[0], a[1]+b[1], a[2]+b[2] }; } +std::array sub3(const std::array& a, const std::array& b) +{ return { a[0]-b[0], a[1]-b[1], a[2]-b[2] }; } + +} // namespace + +std::vector AutoRig::fitTemplateWithMarkers( + const std::vector& tmpl, + const float* verts, int vertexCount, + const std::vector& markers, + const Options& opts, + int* outRecentered, int* outMarkersApplied) +{ + // Proportional baseline — gives sensible default joint positions AND the + // template relationships (segment vectors, lateral offsets) we use to + // INFER unmarked joints from marked ones, so a partial marker set produces + // a coherent skeleton (no shoulder-above-head etc.) instead of mixing + // marked anchors with stranded template joints. + std::vector placed = fitTemplate(tmpl, verts, vertexCount, opts, outRecentered); + if (outMarkersApplied) *outMarkersApplied = 0; + + auto get = [&](MarkerId id) -> const Marker* { + for (const auto& m : markers) + if (m.id == id && m.set) return &m; + return nullptr; + }; + + // No markers placed → the proportional fit is the answer, untouched (keeps + // the "empty marker set ≡ fitTemplate" contract; nothing to infer from). + bool anySet = false; + for (const auto& m : markers) if (m.set) { anySet = true; break; } + if (!anySet) return placed; + + int applied = 0; + + // Mesh AABB (mesh-local space, same coords the fit works in) — used to + // CLAMP inferred joints to the model's extent so an extrapolated limb + // (e.g. up-leg set but knee skipped) can't shoot a foot below the mesh. + std::array mn = { 1e30, 1e30, 1e30}; + std::array mx = {-1e30, -1e30, -1e30}; + 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); + } + } + + // ---- Template reference positions (the proportional fit) ------------- + const auto tHips = jointPosOr(placed, "Hips", {0,0,0}); + const auto tHead = jointPosOr(placed, "Head", tHips); + const auto tLSh = jointPosOr(placed, "LeftShoulder", tHips); + const auto tRSh = jointPosOr(placed, "RightShoulder", tHips); + const auto tLHand = jointPosOr(placed, "LeftHand", tLSh); + const auto tRHand = jointPosOr(placed, "RightHand", tRSh); + const auto tLUp = jointPosOr(placed, "LeftUpLeg", tHips); + const auto tRUp = jointPosOr(placed, "RightUpLeg", tHips); + const auto tLKnee = jointPosOr(placed, "LeftLeg", tLUp); + const auto tRKnee = jointPosOr(placed, "RightLeg", tRUp); + + // Reflect a point across the body's sagittal plane (the plane through Hips + // perpendicular to the side axis). Used to mirror a marked left limb onto + // an unmarked right one (and vice-versa). The side axis is whichever of the + // two non-up axes the template shoulders are most separated along. + const int up = std::clamp(opts.upAxis, 0, 2); + int sideAxis = (up == 0) ? 1 : 0; // first non-up axis + { + const int a1 = (up == 0) ? 1 : 0; + const int a2 = (up == 2) ? 1 : 2; + if (std::abs(tLSh[a2] - tRSh[a2]) > std::abs(tLSh[a1] - tRSh[a1])) + sideAxis = a2; + } + auto mirror = [&](std::array p, const std::array& center) { + p[sideAxis] = center[sideAxis] - (p[sideAxis] - center[sideAxis]); + return p; + }; + + // ---- Resolve anchor positions (marked → inferred → template) --------- + // Each `resolve` records whether a USER marker drove it (for applied count). + const Marker* mHead = get(MarkerId::Chin); + const Marker* mHips = get(MarkerId::Hips); + const Marker* mLSh = get(MarkerId::LeftShoulder); + const Marker* mRSh = get(MarkerId::RightShoulder); + const Marker* mLWr = get(MarkerId::LeftWrist); + const Marker* mRWr = get(MarkerId::RightWrist); + const Marker* mLUp = get(MarkerId::LeftUpLeg); + const Marker* mRUp = get(MarkerId::RightUpLeg); + const Marker* mLKn = get(MarkerId::LeftKnee); + const Marker* mRKn = get(MarkerId::RightKnee); + for (const Marker* m : {mHead,mHips,mLSh,mRSh,mLWr,mRWr,mLUp,mRUp,mLKn,mRKn}) + if (m) ++applied; + + // HIPS: marked → else from the up-legs (midpoint, lifted by the template + // socket→pelvis rise) → else template. + std::array pHips = tHips; + if (mHips) pHips = mHips->pos; + else if (mLUp && mRUp) { + pHips = { 0.5*(mLUp->pos[0]+mRUp->pos[0]), + 0.5*(mLUp->pos[1]+mRUp->pos[1]), + 0.5*(mLUp->pos[2]+mRUp->pos[2]) }; + const auto lift = sub3(tHips, { 0.5*(tLUp[0]+tRUp[0]), + 0.5*(tLUp[1]+tRUp[1]), + 0.5*(tLUp[2]+tRUp[2]) }); + pHips = add3(pHips, lift); + } + + // HEAD (chin): marked → else template lifted to keep the marked-hips offset. + std::array pHead = mHead ? mHead->pos : add3(pHips, sub3(tHead, tHips)); + + // UP-LEGS: marked → else mirror the other marked one across the pelvis → + // else pelvis + template socket offset. + std::array pLUp, pRUp; + pLUp = mLUp ? mLUp->pos : (mRUp ? mirror(mRUp->pos, pHips) : add3(pHips, sub3(tLUp, tHips))); + pRUp = mRUp ? mRUp->pos : (mLUp ? mirror(mLUp->pos, pHips) : add3(pHips, sub3(tRUp, tHips))); + + // SHOULDERS: marked → else mirror the other → else from the spine: place at + // the template's shoulder-height fraction along the live Hips→Head line, + // plus the template lateral offset (so chin+hips imply the shoulders). + auto shoulderFromSpine = [&](const std::array& tSh) { + const double denomUp = (tHead[up] - tHips[up]); + const double f = std::abs(denomUp) > 1e-9 + ? (tSh[up] - tHips[up]) / denomUp : 0.78; + std::array p = lerp3(pHips, pHead, std::clamp(f, 0.0, 1.0)); + // lateral / depth offset of the template shoulder from the spine line + const std::array tSpineAtSh = lerp3(tHips, tHead, std::clamp(f,0.0,1.0)); + const auto off = sub3(tSh, tSpineAtSh); + return add3(p, off); + }; + std::array pLSh, pRSh; + pLSh = mLSh ? mLSh->pos : (mRSh ? mirror(mRSh->pos, pHead) : shoulderFromSpine(tLSh)); + pRSh = mRSh ? mRSh->pos : (mLSh ? mirror(mLSh->pos, pHead) : shoulderFromSpine(tRSh)); + + // HANDS (wrist): marked → else shoulder + template arm vector (so a marked + // shoulder with a skipped wrist still lays a full arm reaching out). + std::array pLHand, pRHand; + pLHand = mLWr ? mLWr->pos : add3(pLSh, sub3(tLHand, tLSh)); + pRHand = mRWr ? mRWr->pos : add3(pRSh, sub3(tRHand, tRSh)); + + // KNEES + FEET: resolve both, clamped to the mesh's lower extent so an + // inferred leg never punches through the bottom of the model. + // * knee marked → knee at the marker, foot extrapolated below + // (knee + thigh→knee), then clamped to the floor. + // * knee unmarked → drop the foot to the mesh FLOOR (mn[up]) straight + // below the up-leg, and put the knee halfway between the + // up-leg and that foot. (Template thigh-vector + // extrapolation is what shot feet past the mesh limit; + // anchoring the foot to the floor fixes that.) + const double floorUp = mn[up]; + auto resolveLeg = [&](const std::array& upPos, + const std::array& tKnee, + const std::array& tUp, + const Marker* kneeMk, + std::array& knee, + std::array& foot) { + if (kneeMk) { + knee = kneeMk->pos; + foot = add3(knee, sub3(knee, upPos)); // continue below the knee + } else { + // Foot straight below the up-leg, sitting on the mesh floor. + foot = upPos; foot[up] = floorUp; + knee = { 0.5*(upPos[0]+foot[0]), + 0.5*(upPos[1]+foot[1]), + 0.5*(upPos[2]+foot[2]) }; + // Nudge the knee slightly forward (template thigh→knee in-plane + // direction) so it isn't a perfectly straight, lockable line. + const auto tIn = sub3(tKnee, tUp); + for (int a = 0; a < 3; ++a) if (a != up) knee[a] += tIn[a] * 0.25; + } + // Never let the foot go below the mesh floor (clamp the up coord). + if (foot[up] < floorUp) foot[up] = floorUp; + // Keep the knee strictly between the up-leg and the foot in up-coord. + const double lo = std::min(upPos[up], foot[up]); + const double hi = std::max(upPos[up], foot[up]); + knee[up] = std::clamp(knee[up], lo, hi); + }; + std::array pLKnee, pLFoot, pRKnee, pRFoot; + resolveLeg(pLUp, tLKnee, tLUp, mLKn, pLKnee, pLFoot); + resolveLeg(pRUp, tRKnee, tRUp, mRKn, pRKnee, pRFoot); + + // ---- Write the resolved anchors back, then lay the dependent chains -- + auto setJoint = [&](const char* name, const std::array& p) { + if (auto* j = findJoint(placed, name)) j->pos = p; + }; + setJoint("Hips", pHips); + setJoint("Head", pHead); + setJoint("LeftShoulder", pLSh); + setJoint("RightShoulder", pRSh); + setJoint("LeftUpLeg", pLUp); + setJoint("RightUpLeg", pRUp); + + // Spine: distribute Spine/Chest/Neck evenly between Hips and Head. + { + static const char* kSpine[] = { "Spine", "Chest", "Neck" }; + const int last = static_cast(std::size(kSpine)) + 1; // +Head + for (int i = 0; i < static_cast(std::size(kSpine)); ++i) + setJoint(kSpine[i], lerp3(pHips, pHead, + static_cast(i + 1) / last)); + } + // Arms: lay the full chain shoulder→arm→forearm→hand toward the resolved hand. + layChain(placed, {"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand"}, pLHand); + layChain(placed, {"RightShoulder", "RightArm", "RightForeArm", "RightHand"}, pRHand); + + // Legs: write the resolved (and floor-clamped) knee + foot anchors. + setJoint("LeftLeg", pLKnee); setJoint("LeftFoot", pLFoot); + setJoint("RightLeg", pRKnee); setJoint("RightFoot", pRFoot); + + if (outMarkersApplied) *outMarkersApplied = applied; + 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) +{ + return rigEntityWithMarkers(entity, /*markers=*/{}, opts); +} + +AutoRig::Report AutoRig::rigEntityWithMarkers(Ogre::Entity* entity, + const std::vector& markers, + 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 — marker-driven when markers are supplied, else the + // plain proportional fit. + int recentered = 0, markersApplied = 0; + const std::vector tmpl = templateJoints(opts.tmpl); + const std::vector placed = markers.empty() + ? fitTemplate(tmpl, verts.data(), vcount, opts, &recentered) + : fitTemplateWithMarkers(tmpl, verts.data(), vcount, markers, opts, + &recentered, &markersApplied); + report.jointsRecentered = recentered; + report.markersApplied = markersApplied; + + // 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; +} + +bool AutoRig::unrigEntity(Ogre::Entity* entity) +{ + if (!entity || !entity->getMesh()) return false; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh->hasSkeleton()) return false; + + // Remember the skeleton resource name so we can free it after detaching. + const std::string skelName = mesh->getSkeletonName(); + + // Strip the BLEND_INDICES/BLEND_WEIGHTS vertex elements that the skin step + // (`_compileBoneAssignments`) added. CRUCIAL for undo when the rig was + // committed with skinning: clearing the bone-assignment LIST is not enough + // — Ogre's `_compileBoneAssignments` only *removes* the blend elements + // inside `compileBoneAssignments`, which it skips entirely when the list is + // empty (maxBones == 0). So a plain clear leaves the vertex declaration + // advertising blend elements while the entity has no skeleton, and the next + // `_initialise`/render dereferences a null SkeletonInstance → crash. We + // mirror Ogre's own removal block (unset the buffer, drop both elements). + auto stripBlend = [](Ogre::VertexData* vd) { + if (!vd) return; + Ogre::VertexDeclaration* decl = vd->vertexDeclaration; + Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; + const Ogre::VertexElement* e = + decl->findElementBySemantic(Ogre::VES_BLEND_INDICES); + if (!e) return; + bind->unsetBinding(e->getSource()); + decl->removeElement(Ogre::VES_BLEND_INDICES); + decl->removeElement(Ogre::VES_BLEND_WEIGHTS); + }; + + // Drop every bone assignment (shared + per-submesh) so the mesh carries no + // stale weights once it's static again, then strip the blend elements. + mesh->clearBoneAssignments(); + stripBlend(mesh->sharedVertexData); + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + if (Ogre::SubMesh* sub = mesh->getSubMesh(si)) { + sub->clearBoneAssignments(); + if (!sub->useSharedVertices) + stripBlend(sub->vertexData); + } + } + + // Detach the skeleton and force the entity back to its static form (mirror + // of the rig path's _notifySkeleton + _initialise(true)). + mesh->_notifySkeleton(Ogre::SkeletonPtr()); + entity->_initialise(true); + + auto& skelMgr = Ogre::SkeletonManager::getSingleton(); + if (!skelName.empty() && skelMgr.resourceExists(skelName)) + skelMgr.remove(skelName); + return true; +} + +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 000000000..674b76472 --- /dev/null +++ b/src/AutoRig.h @@ -0,0 +1,199 @@ +#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; + }; + + // Mixamo-style placement markers (humanoid). The user clicks these on the + // mesh surface; the marker positions anchor the corresponding joints and + // the limb chains interpolate between them, so the rig follows the actual + // body proportions instead of a fixed proportional template. Every marker + // is OPTIONAL — an unset marker leaves its joint(s) at the template fit. + enum class MarkerId { + Chin, // anchors Head; spine/neck interpolate Hips→Chin + LeftShoulder, // anchors LeftShoulder (arm-chain attach point) + RightShoulder, // anchors RightShoulder + LeftWrist, // anchors LeftHand (+ LeftArm/LeftForeArm chain) + RightWrist, // anchors RightHand (+ RightArm/RightForeArm chain) + LeftUpLeg, // anchors LeftUpLeg (leg-chain attach / hip socket) + RightUpLeg, // anchors RightUpLeg + LeftKnee, // anchors LeftLeg (+ LeftFoot extrapolated) + RightKnee, // anchors RightLeg (+ RightFoot extrapolated) + Hips, // anchors Hips (pelvis height/centre) + Count + }; + + struct Marker { + MarkerId id = MarkerId::Count; + bool set = false; // false = not placed → joint uses template + std::array pos = {0, 0, 0}; // mesh-local position + }; + + // Stable label for a marker slot (UI + tests). + static QString markerLabel(MarkerId id); + // The ordered marker set the humanoid flow asks for (10 markers). + static std::vector humanoidMarkerOrder(); + + struct Report { + QString meshName; + QString skeletonName; + QString templateName; + int boneCount = 0; + int verticesSampled = 0; + int jointsRecentered = 0; + int markersApplied = 0; // how many placed markers drove the fit + 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 = {}); + + // Marker-guided variant: same as rigEntity but anchors the placed markers + // (and interpolates the limb chains between them) before building the + // skeleton. Markers are in mesh-local space. Unset markers fall back to the + // proportional template fit. report.markersApplied counts the placed ones. + static Report rigEntityWithMarkers(Ogre::Entity* entity, + const std::vector& markers, + const Options& opts = {}); + + // Revert a mesh auto-rigged by rigEntity[WithMarkers] back to a static + // (skeleton-less) mesh: clears every submesh's (and the shared) bone + // assignments, detaches the skeleton, re-initialises the entity, and + // removes the `*_autorig` SkeletonManager resource. This is the undo + // primitive for AutoRigCommand — it only makes sense for a mesh that was + // static before rigging (which is the only thing auto-rig accepts), so it + // unconditionally strips the skeleton rather than restoring a prior one. + // Returns true if a skeleton was present and removed. + static bool unrigEntity(Ogre::Entity* entity); + + // --- 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); + + // Marker-driven fit: runs fitTemplate, then anchors the placed markers and + // interpolates the limb chains between them (unset markers keep the + // template fit). `outMarkersApplied` (optional) receives how many set + // markers actually drove a joint. Pure-data — the heart of the marker flow. + static std::vector fitTemplateWithMarkers(const std::vector& tmpl, + const float* vertexPositions, + int vertexCount, + const std::vector& markers, + const Options& opts, + int* outRecentered = nullptr, + int* outMarkersApplied = 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 000000000..6ceec6ced --- /dev/null +++ b/src/AutoRigController.cpp @@ -0,0 +1,542 @@ +#include "AutoRigController.h" +#include "AutoRig.h" +#include "SkinWeights.h" +#include "SelectionSet.h" +#include "SentryReporter.h" +#include "Manager.h" +#include "OgreWidget.h" +#include "SpaceCamera.h" +#include "UndoManager.h" +#include "PropertiesPanelController.h" +#include "commands/AutoRigCommand.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Read an entity's mesh into tightly-packed world-space triangle vertices. +// (Self-contained — does not depend on Edit Mode's EditableMesh.) Used for the +// marker ray-cast. Returns false if no readable geometry. +bool gatherWorldTriangles(Ogre::Entity* entity, std::vector& outTris) +{ + if (!entity || !entity->getMesh()) return false; + Ogre::MeshPtr mesh = entity->getMesh(); + Ogre::Node* node = entity->getParentSceneNode(); + const Ogre::Affine3 xform = node ? node->_getFullTransform() : Ogre::Affine3::IDENTITY; + + auto readVB = [](Ogre::VertexData* vd, std::vector& pos) { + if (!vd) return; + const auto* pe = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!pe) return; + auto vb = vd->vertexBufferBinding->getBuffer(pe->getSource()); + if (!vb) return; + const size_t stride = vb->getVertexSize(); + auto* base = static_cast(vb->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!base) return; + const size_t start = pos.size(); + pos.resize(start + vd->vertexCount); + for (size_t i = 0; i < vd->vertexCount; ++i) { + float* p = nullptr; + pe->baseVertexPointerToElement(base + i * stride, &p); + pos[start + i] = Ogre::Vector3(p[0], p[1], p[2]); + } + vb->unlock(); + }; + + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (!sub) continue; + + std::vector pos; // local-space vertex positions for this submesh + Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData : sub->vertexData; + readVB(vd, pos); + if (pos.empty()) continue; + + Ogre::IndexData* id = sub->indexData; + if (!id || !id->indexBuffer || id->indexCount < 3) continue; + auto ib = id->indexBuffer; + const bool is32 = ib->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + auto* idxBase = static_cast(ib->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!idxBase) continue; + const auto* i32 = reinterpret_cast(idxBase); + const auto* i16 = reinterpret_cast(idxBase); + for (size_t t = 0; t + 2 < id->indexCount; t += 3) { + const uint32_t a = is32 ? i32[t] : i16[t]; + const uint32_t b = is32 ? i32[t+1] : i16[t+1]; + const uint32_t c = is32 ? i32[t+2] : i16[t+2]; + if (a >= pos.size() || b >= pos.size() || c >= pos.size()) continue; + outTris.push_back(xform * pos[a]); + outTris.push_back(xform * pos[b]); + outTris.push_back(xform * pos[c]); + } + ib->unlock(); + } + return !outTris.empty(); +} + +// Möller-Trumbore; returns t>0 on hit else -1. +float rayTri(const Ogre::Vector3& o, const Ogre::Vector3& d, + const Ogre::Vector3& v0, const Ogre::Vector3& v1, const Ogre::Vector3& v2) +{ + const Ogre::Vector3 e1 = v1 - v0, e2 = v2 - v0; + const Ogre::Vector3 p = d.crossProduct(e2); + const float det = e1.dotProduct(p); + if (std::abs(det) < 1e-8f) return -1.0f; + const float inv = 1.0f / det; + const Ogre::Vector3 tv = o - v0; + const float u = tv.dotProduct(p) * inv; + if (u < 0 || u > 1) return -1.0f; + const Ogre::Vector3 q = tv.crossProduct(e1); + const float v = d.dotProduct(q) * inv; + if (v < 0 || u + v > 1) return -1.0f; + const float t = e2.dotProduct(q) * inv; + return t > 1e-6f ? t : -1.0f; +} + +} // namespace + +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))); + + // Pre-check here so a non-static mesh fails cleanly WITHOUT leaving a + // no-op entry on the undo stack (QUndoStack::push runs redo()). + if (entity->getMesh()->hasSkeleton()) { + const auto msg = QStringLiteral( + "Mesh already has a skeleton — auto-rig only applies to unrigged " + "(static) meshes."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + + m_busy = true; + emit busyChanged(); + + AutoRig::Report report; + bool skinned = false; + try { + // Run through an undo command so rig (+ optional skin) reverts with + // Ctrl+Z. push() executes redo() synchronously; read back the report. + auto* cmd = new AutoRigCommand(entity->getName(), opts, {}, alsoSkin); + UndoManager::getSingleton()->push(cmd); + report = cmd->report(); + skinned = cmd->skinned(); + } 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; +} + +// ============================ Marker placement ============================ + +Ogre::Entity* AutoRigController::selectedRiggableEntity() const +{ + auto* sel = SelectionSet::getSingleton(); + const auto ents = sel ? sel->getResolvedEntities() : QList{}; + if (ents.isEmpty()) return nullptr; + Ogre::Entity* e = ents.first(); + if (!e || !e->getMesh() || e->getMesh()->getSkeleton() != nullptr) return nullptr; + return e; +} + +int AutoRigController::markerCount() const +{ + // Slots resolved so far (placed OR skipped) = the cursor position. Drives + // the "N/total" progress readout in the UI. + return m_markerCursor; +} + +int AutoRigController::markerTotal() const +{ + return static_cast(m_markerOrder.size()); +} + +int AutoRigController::markerPlacedCount() const +{ + // Only the actually-placed (set) markers — what "Rig from markers" needs. + return static_cast(m_markers.size()); +} + +QString AutoRigController::currentMarkerLabel() const +{ + // The slot at the cursor (empty once every slot is resolved). + if (m_markerCursor < 0 || m_markerCursor >= static_cast(m_markerOrder.size())) + return QString(); + return AutoRig::markerLabel(m_markerOrder[m_markerCursor]); +} + +bool AutoRigController::beginMarkerPlacement(const QString& upAxis) +{ + Ogre::Entity* e = selectedRiggableEntity(); + if (!e) { emit error(QStringLiteral("Select a static (unrigged) mesh first.")); return false; } + + const QString ax = upAxis.trimmed().toLower(); + m_upAxis = (ax == "x") ? 0 : (ax == "z") ? 2 : 1; + m_markerEntityName = e->getName(); + m_markerOrder = AutoRig::humanoidMarkerOrder(); + m_markers.clear(); + m_markerCursor = 0; + clearMarkerOverlays(); + m_markerMode = true; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("marker placement begin entity=%1") + .arg(QString::fromStdString(m_markerEntityName))); + emit markerModeChanged(); + emit markerCountChanged(); + return true; +} + +void AutoRigController::cancelMarkerPlacement() +{ + if (!m_markerMode) return; + m_markerMode = false; + m_markers.clear(); + m_markerOrder.clear(); + m_markerCursor = 0; + clearMarkerOverlays(); + emit markerModeChanged(); + emit markerCountChanged(); +} + +void AutoRigController::notifyRiggingChanged(const std::string& entityName) +{ + // Drop any skeleton-debug overlay on this entity — once the skeleton state + // flips (rig ↔ unrig on undo/redo) a previously-shown overlay references a + // skeleton instance that's being recreated/destroyed, which would dangle. + if (auto* ppc = PropertiesPanelController::instance()) + ppc->toggleSkeletonDebug(QString::fromStdString(entityName), false); + // Re-evaluate the Inspector Rigging / Skeleton section visibility. + emit selectionChanged(); +} + +void AutoRigController::skipCurrentMarker() +{ + if (!m_markerMode) return; + if (m_markerCursor >= static_cast(m_markerOrder.size())) return; + // Just advance the cursor past this slot — no marker is stored, so the + // joint keeps its template fit. (No m_markers entry; the cursor is what + // makes currentMarkerLabel move on.) + ++m_markerCursor; + emit markerCountChanged(); +} + +void AutoRigController::undoLastMarker() +{ + if (!m_markerMode || m_markerCursor <= 0) return; + // Step back one slot. If that slot was PLACED (its id is in m_markers), + // drop the marker too; if it was skipped, there's nothing to remove. + --m_markerCursor; + const AutoRig::MarkerId id = m_markerOrder[m_markerCursor]; + for (auto it = m_markers.begin(); it != m_markers.end(); ++it) { + if (it->id == id) { m_markers.erase(it); break; } + } + refreshMarkerOverlays(); + emit markerCountChanged(); +} + +bool AutoRigController::handleMarkerClick(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_markerMode || !widget) return false; + if (m_markerCursor < 0 || m_markerCursor >= static_cast(m_markerOrder.size())) + return true; // all slots resolved; consume click but do nothing + const AutoRig::MarkerId curId = m_markerOrder[m_markerCursor]; + const QString cur = AutoRig::markerLabel(curId); + + Ogre::Entity* e = selectedRiggableEntity(); + if (!e || e->getName() != m_markerEntityName) { + // Selection changed out from under us — abort marker mode. + cancelMarkerPlacement(); + return false; + } + + auto* spaceCam = widget->getSpaceCamera(); + auto* cam = spaceCam ? spaceCam->getCamera() : nullptr; + if (!cam) return true; + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return true; + + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = cam->getCameraToViewportRay(nx, ny); + + std::vector tris; + if (!gatherWorldTriangles(e, tris)) return true; + + float bestT = std::numeric_limits::infinity(); + Ogre::Vector3 hit; + bool found = false; + for (size_t i = 0; i + 2 < tris.size(); i += 3) { + const float t = rayTri(ray.getOrigin(), ray.getDirection(), tris[i], tris[i+1], tris[i+2]); + if (t > 0 && t < bestT) { bestT = t; hit = ray.getOrigin() + ray.getDirection() * t; found = true; } + } + if (!found) return true; // missed the mesh — consume (don't select something else) + + // Store the marker in MESH-LOCAL space (the fit works in local coords). + Ogre::Node* node = e->getParentSceneNode(); + const Ogre::Vector3 local = node + ? node->_getFullTransform().inverse() * hit : hit; + + // Record the marker for the current slot and advance the cursor. + AutoRig::Marker m; + m.id = curId; m.set = true; + m.pos = { local.x, local.y, local.z }; + m_markers.push_back(m); + ++m_markerCursor; + + refreshMarkerOverlays(); + emit markerPlaced(cur); + emit markerCountChanged(); + return true; +} + +QVariantMap AutoRigController::commitMarkerRig(bool alsoSkin) +{ + QVariantMap result; + Ogre::Entity* entity = selectedRiggableEntity(); + if (!entity || entity->getName() != m_markerEntityName) { + const auto msg = QStringLiteral("Selected mesh is no longer valid for rigging."); + emit error(msg); result["applied"] = false; result["error"] = msg; + cancelMarkerPlacement(); + return result; + } + + // Collect only the SET markers (placed ones); skipped/unset fall back. + std::vector placed; + for (const auto& m : m_markers) if (m.set) placed.push_back(m); + + AutoRig::Options opts; + opts.tmpl = AutoRig::Template::Humanoid; // markers are a humanoid concept + opts.upAxis = m_upAxis; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("marker rig commit entity=%1 markers=%2") + .arg(QString::fromStdString(m_markerEntityName)).arg(placed.size())); + + m_busy = true; emit busyChanged(); + AutoRig::Report report; + bool skinned = false; + try { + // Undoable, same as autoRigSelected — markers ride along in the command. + auto* cmd = new AutoRigCommand(entity->getName(), opts, placed, alsoSkin); + UndoManager::getSingleton()->push(cmd); + report = cmd->report(); + skinned = cmd->skinned(); + } catch (const Ogre::Exception& ex) { + m_busy = false; emit busyChanged(); + const auto msg = QString::fromStdString(ex.getFullDescription()); + emit error(QStringLiteral("Ogre error: %1").arg(msg)); + result["applied"] = false; result["error"] = msg; + return result; + } + + // Leave marker mode (clears overlays) regardless of outcome. + m_markerMode = false; + m_markers.clear(); + m_markerOrder.clear(); + clearMarkerOverlays(); + emit markerModeChanged(); + + m_busy = false; emit busyChanged(); + emit selectionChanged(); + + result["applied"] = report.applied; + result["meshName"] = report.meshName; + result["boneCount"] = report.boneCount; + result["markersApplied"] = report.markersApplied; + 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; +} + +void AutoRigController::clearMarkerOverlays() +{ + auto* mgr = Manager::getSingletonPtr(); + Ogre::SceneManager* scene = mgr ? mgr->getSceneMgr() : nullptr; + for (Ogre::SceneNode* n : m_markerNodes) { + if (!n) continue; + n->removeAndDestroyAllChildren(); + if (scene) { + // Destroy attached entities then the node. + auto objs = n->getAttachedObjects(); + for (auto* o : objs) scene->destroyMovableObject(o); + scene->destroySceneNode(n); + } + } + m_markerNodes.clear(); +} + +void AutoRigController::refreshMarkerOverlays() +{ + clearMarkerOverlays(); + auto* mgr = Manager::getSingletonPtr(); + Ogre::SceneManager* scene = mgr ? mgr->getSceneMgr() : nullptr; + Ogre::Entity* e = selectedRiggableEntity(); + if (!scene || !e) return; + Ogre::Node* node = e->getParentSceneNode(); + + // Small unit sphere mesh + bright unlit material, created once. + const std::string meshName = "__AutoRigMarkerSphere__"; + if (!Ogre::MeshManager::getSingleton().resourceExists(meshName)) { + Ogre::MeshManager::getSingleton().createManual(meshName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + // Use Ogre's built-in sphere via the prefab if manual gen is unavailable. + } + const std::string matName = "__AutoRigMarkerMat__"; + auto& mm = Ogre::MaterialManager::getSingleton(); + if (!mm.resourceExists(matName)) { + auto mat = mm.create(matName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + pass->setLightingEnabled(false); + pass->setDiffuse(Ogre::ColourValue(1.0f, 0.85f, 0.1f, 1.0f)); + pass->setAmbient(Ogre::ColourValue(1.0f, 0.85f, 0.1f, 1.0f)); + pass->setDepthCheckEnabled(false); // always visible over the mesh + } + + // Marker world size ~3% of the mesh's bounding radius. + const Ogre::Real r = e->getBoundingRadius() * 0.03f; + + for (const auto& m : m_markers) { + if (!m.set) continue; + const Ogre::Vector3 localPos( + static_cast(m.pos[0]), + static_cast(m.pos[1]), + static_cast(m.pos[2])); + const Ogre::Vector3 worldPos = node ? node->_getFullTransform() * localPos : localPos; + + Ogre::SceneNode* sn = scene->getRootSceneNode()->createChildSceneNode(); + Ogre::Entity* sphere = nullptr; + try { + sphere = scene->createEntity(Ogre::SceneManager::PT_SPHERE); + } catch (...) { sphere = nullptr; } + if (sphere) { + sphere->setMaterialName(matName); + sphere->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY - 1); + sn->attachObject(sphere); + // Ogre's PT_SPHERE has radius 100; scale to the desired world radius. + const Ogre::Real s = (r > 1e-4f ? r : 0.02f) / 100.0f; + sn->setScale(s, s, s); + } + sn->setPosition(worldPos); + m_markerNodes.push_back(sn); + } +} diff --git a/src/AutoRigController.h b/src/AutoRigController.h new file mode 100644 index 000000000..c32f6e627 --- /dev/null +++ b/src/AutoRigController.h @@ -0,0 +1,125 @@ +#ifndef AUTO_RIG_CONTROLLER_H +#define AUTO_RIG_CONTROLLER_H + +#include +#include +#include +#include +#include + +#include "AutoRig.h" + +class OgreWidget; +namespace Ogre { class Entity; class SceneNode; } + +// 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. +// +// It also drives the Mixamo-style MARKER placement flow: the user enters +// marker mode, clicks the 10 humanoid markers on the mesh in the viewport +// (routed in via TransformOperator), and commits — the markers anchor the +// matching joints and the limb chains interpolate between them. +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) + // Marker-placement session state (for the QML guided UX). + Q_PROPERTY(bool markerMode READ markerMode NOTIFY markerModeChanged) + Q_PROPERTY(int markerCount READ markerCount NOTIFY markerCountChanged) + Q_PROPERTY(int markerTotal READ markerTotal NOTIFY markerModeChanged) + Q_PROPERTY(int markerPlacedCount READ markerPlacedCount NOTIFY markerCountChanged) + Q_PROPERTY(QString currentMarkerLabel READ currentMarkerLabel NOTIFY markerCountChanged) + +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); + + // ---- Marker placement (Mixamo-style) ------------------------------- + bool markerMode() const { return m_markerMode; } + int markerCount() const; // slots resolved (placed+skipped) + int markerTotal() const; // total expected (10 for humanoid) + int markerPlacedCount() const; // only the placed (set) markers + QString currentMarkerLabel() const; // label of the next marker to place + + /// Enter marker mode for the selected static mesh. Subsequent viewport + /// clicks place the markers (chin, L/R shoulder, L/R wrist, L/R hip, + /// L/R knee, hips/pelvis in order). + Q_INVOKABLE bool beginMarkerPlacement(const QString& upAxis); + /// Leave marker mode, discarding any placed markers + their overlays. + Q_INVOKABLE void cancelMarkerPlacement(); + /// Skip the current marker (leaves that joint at the template fit). + Q_INVOKABLE void skipCurrentMarker(); + /// Remove the last placed marker (undo one click). + Q_INVOKABLE void undoLastMarker(); + /// Build the rig from the placed markers (+ optional skin). Returns a + /// QVariantMap like autoRigSelected. + Q_INVOKABLE QVariantMap commitMarkerRig(bool alsoSkin); + + /// Called by TransformOperator when a viewport click happens while marker + /// mode is active. Ray-casts to the mesh surface and records the marker. + /// Returns true if the click was consumed (so the operator skips select). + bool handleMarkerClick(OgreWidget* widget, const QPoint& screenPos); + + /// Called by AutoRigCommand after a rig/unrig (incl. undo/redo). Drops any + /// active skeleton-debug overlay on the entity (it would dangle once the + /// skeleton state flips) and emits selectionChanged so the Inspector + /// re-evaluates the Rigging / Skeleton sections. + void notifyRiggingChanged(const std::string& entityName); + +signals: + void selectionChanged(); + void busyChanged(); + void rigged(const QVariantMap& report); + void error(const QString& message); + void markerModeChanged(); + void markerCountChanged(); + void markerPlaced(const QString& label); + +private: + AutoRigController(); + ~AutoRigController() override = default; + + Ogre::Entity* selectedRiggableEntity() const; + void clearMarkerOverlays(); + void refreshMarkerOverlays(); + + static AutoRigController* m_pSingleton; + bool m_busy = false; + + // Marker session. Progress is a CURSOR into m_markerOrder: slots before the + // cursor are resolved (either placed in m_markers, or skipped — absent from + // m_markers). The cursor — not the contents of m_markers — drives which + // marker is "current", so Skip advances past a slot without placing it and + // the cursor never sticks. m_markers holds only the placed (set) markers. + bool m_markerMode = false; + int m_upAxis = 1; // resolved at begin + int m_markerCursor = 0; // next slot to resolve + std::vector m_markerOrder; // the 10, in click order + std::vector m_markers; // PLACED markers (set) + std::vector m_markerNodes; // viewport sphere overlays + std::string m_markerEntityName; // entity being marked +}; + +#endif // AUTO_RIG_CONTROLLER_H diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp new file mode 100644 index 000000000..28164f06f --- /dev/null +++ b/src/AutoRig_test.cpp @@ -0,0 +1,576 @@ +// 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")); +} + +// ---- Marker-driven fit (#407 Mixamo-style) ------------------------------ + +namespace { +// Distance between two joint positions. +double jdist(const AutoRig::Joint& a, const AutoRig::Joint& b) +{ + double dx = a.pos[0] - b.pos[0]; + double dy = a.pos[1] - b.pos[1]; + double dz = a.pos[2] - b.pos[2]; + return std::sqrt(dx * dx + dy * dy + dz * dz); +} +int jindex(const std::vector& js, const QString& name) +{ + for (int i = 0; i < static_cast(js.size()); ++i) + if (js[i].name == name) return i; + return -1; +} +} // namespace + +TEST(AutoRigMarkers, OrderAndLabelsAreStable) +{ + const auto order = AutoRig::humanoidMarkerOrder(); + ASSERT_EQ(order.size(), 10u); + EXPECT_EQ(order.front(), AutoRig::MarkerId::Chin); // top-down: chin first + EXPECT_EQ(order.back(), AutoRig::MarkerId::Hips); // pelvis last + for (auto id : order) + EXPECT_FALSE(AutoRig::markerLabel(id).isEmpty()); +} + +TEST(AutoRigMarkers, EmptyMarkersMatchPlainFit) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; // +Y up, humanoid + + int recenterA = 0, recenterB = 0, applied = -1; + auto plain = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + opts, &recenterA); + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {}, opts, &recenterB, &applied); + ASSERT_EQ(plain.size(), marked.size()); + EXPECT_EQ(applied, 0); + for (size_t i = 0; i < plain.size(); ++i) + EXPECT_LT(jdist(plain[i], marked[i]), 1e-6) << "joint " << i; +} + +TEST(AutoRigMarkers, WristMarkerLaysWholeArmChainTowardIt) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + // Skip if this template doesn't expose the named arm chain. + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iShoulder = jindex(base, "LeftShoulder"); + const int iArm = jindex(base, "LeftArm"); + const int iFore = jindex(base, "LeftForeArm"); + const int iHand = jindex(base, "LeftHand"); + if (iShoulder < 0 || iArm < 0 || iFore < 0 || iHand < 0) + GTEST_SKIP() << "no left-arm chain"; + + AutoRig::Marker wrist; + wrist.id = AutoRig::MarkerId::LeftWrist; + wrist.set = true; + wrist.pos = {1.25, 1.55, 0.10}; // far out from the body + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {wrist}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + + // Hand lands exactly on the marker. + EXPECT_LT(std::abs(marked[iHand].pos[0] - wrist.pos[0]), 1e-6); + EXPECT_LT(std::abs(marked[iHand].pos[1] - wrist.pos[1]), 1e-6); + EXPECT_LT(std::abs(marked[iHand].pos[2] - wrist.pos[2]), 1e-6); + + // Shoulder (the anchor) is unchanged from the template fit. + EXPECT_LT(jdist(marked[iShoulder], base[iShoulder]), 1e-6); + + // The intermediate joints lie evenly on the shoulder→hand segment: + // LeftArm at 1/3, LeftForeArm at 2/3. + const auto& a = marked[iShoulder].pos; + for (int k = 0; k < 3; ++k) { + const double arm13 = a[k] + (wrist.pos[k] - a[k]) * (1.0 / 3.0); + const double fore23 = a[k] + (wrist.pos[k] - a[k]) * (2.0 / 3.0); + EXPECT_LT(std::abs(marked[iArm].pos[k] - arm13), 1e-6) << "arm axis " << k; + EXPECT_LT(std::abs(marked[iFore].pos[k] - fore23), 1e-6) << "fore axis " << k; + } + + // The upper arm (LeftArm) actually moved OUT toward the wrist — the bug we + // fixed was that it stayed at its tucked template x while only the wrist moved. + EXPECT_GT(std::abs(marked[iArm].pos[0]), std::abs(base[iArm].pos[0])); +} + +TEST(AutoRigMarkers, HipsMarkerAnchorsPelvisOnly) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + if (iHips < 0) GTEST_SKIP() << "no Hips joint"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; + hips.set = true; + hips.pos = {0.05, 0.9, 0.0}; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {hips}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + EXPECT_LT(jdist(marked[iHips], AutoRig::Joint{"", -1, hips.pos}), 1e-6); +} + +TEST(AutoRigMarkers, OrderHasTenWithAttachPointsBeforeTips) +{ + const auto order = AutoRig::humanoidMarkerOrder(); + ASSERT_EQ(order.size(), 10u); + auto pos = [&](AutoRig::MarkerId id) { + for (size_t i = 0; i < order.size(); ++i) if (order[i] == id) return (int)i; + return -1; + }; + // Attach points precede their tips: shoulder→wrist, hip→knee. + EXPECT_GE(pos(AutoRig::MarkerId::LeftShoulder), 0); + EXPECT_GE(pos(AutoRig::MarkerId::LeftUpLeg), 0); + EXPECT_LT(pos(AutoRig::MarkerId::LeftShoulder), pos(AutoRig::MarkerId::LeftWrist)); + EXPECT_LT(pos(AutoRig::MarkerId::RightShoulder), pos(AutoRig::MarkerId::RightWrist)); + EXPECT_LT(pos(AutoRig::MarkerId::LeftUpLeg), pos(AutoRig::MarkerId::LeftKnee)); + EXPECT_LT(pos(AutoRig::MarkerId::RightUpLeg), pos(AutoRig::MarkerId::RightKnee)); + EXPECT_FALSE(AutoRig::markerLabel(AutoRig::MarkerId::LeftUpLeg).isEmpty()); + EXPECT_FALSE(AutoRig::markerLabel(AutoRig::MarkerId::LeftShoulder).isEmpty()); +} + +TEST(AutoRigMarkers, ChinAndHipsLaySpineBetweenThem) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + const int iSpine = jindex(base, "Spine"); + const int iChest = jindex(base, "Chest"); + const int iNeck = jindex(base, "Neck"); + const int iHead = jindex(base, "Head"); + if (iHips < 0 || iSpine < 0 || iChest < 0 || iNeck < 0 || iHead < 0) + GTEST_SKIP() << "no spine chain"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; hips.set = true; hips.pos = {0.0, 0.80, 0.0}; + AutoRig::Marker chin; + chin.id = AutoRig::MarkerId::Chin; chin.set = true; chin.pos = {0.0, 2.20, 0.0}; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {hips, chin}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + + // Head=chin, Hips=hips, and Spine/Chest/Neck distributed evenly on the + // segment: last = 3 spine joints + Head = 4 steps, so Spine@1/4, Chest@2/4, + // Neck@3/4 between hips and chin. + EXPECT_LT(jdist(marked[iHead], AutoRig::Joint{"", -1, chin.pos}), 1e-6); + EXPECT_LT(jdist(marked[iHips], AutoRig::Joint{"", -1, hips.pos}), 1e-6); + const auto& a = hips.pos; + auto onSeg = [&](double t) { + return std::array{ a[0]+(chin.pos[0]-a[0])*t, + a[1]+(chin.pos[1]-a[1])*t, + a[2]+(chin.pos[2]-a[2])*t }; + }; + EXPECT_LT(jdist(marked[iSpine], AutoRig::Joint{"", -1, onSeg(1.0/4)}), 1e-6); + EXPECT_LT(jdist(marked[iChest], AutoRig::Joint{"", -1, onSeg(2.0/4)}), 1e-6); + EXPECT_LT(jdist(marked[iNeck], AutoRig::Joint{"", -1, onSeg(3.0/4)}), 1e-6); +} + +TEST(AutoRigMarkers, HipsCarriesThighRootsAndKneeLaysLowerLeg) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + const int iUpLeg = jindex(base, "LeftUpLeg"); + const int iKnee = jindex(base, "LeftLeg"); + const int iFoot = jindex(base, "LeftFoot"); + if (iHips < 0 || iUpLeg < 0 || iKnee < 0 || iFoot < 0) + GTEST_SKIP() << "no left-leg chain"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; + hips.set = true; + hips.pos = {0.0, 1.05, 0.0}; + AutoRig::Marker knee; + knee.id = AutoRig::MarkerId::LeftKnee; + knee.set = true; + knee.pos = {0.40, 0.55, 0.05}; + + // Expected thigh-root shift = the hips delta (UpLeg is carried with Hips). + const auto& bH = base[iHips].pos; + const std::array d = { hips.pos[0]-bH[0], hips.pos[1]-bH[1], hips.pos[2]-bH[2] }; + const std::array expUpLeg = { base[iUpLeg].pos[0]+d[0], + base[iUpLeg].pos[1]+d[1], + base[iUpLeg].pos[2]+d[2] }; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {hips, knee}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + + // Thigh root tracked the hips marker (didn't stay at its template pos). + EXPECT_LT(jdist(marked[iUpLeg], AutoRig::Joint{"", -1, expUpLeg}), 1e-6); + // Knee landed on its marker. + EXPECT_LT(jdist(marked[iKnee], AutoRig::Joint{"", -1, knee.pos}), 1e-6); + // Foot continues below the knee along thigh→knee (knee + (knee - upLeg)). + const auto& U = marked[iUpLeg].pos; + const std::array expFoot = { knee.pos[0] + (knee.pos[0]-U[0]), + knee.pos[1] + (knee.pos[1]-U[1]), + knee.pos[2] + (knee.pos[2]-U[2]) }; + EXPECT_LT(jdist(marked[iFoot], AutoRig::Joint{"", -1, expFoot}), 1e-6); +} + +TEST(AutoRigMarkers, ShoulderMarkerAnchorsAttachAndArmLaysFromIt) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iShoulder = jindex(base, "LeftShoulder"); + const int iArm = jindex(base, "LeftArm"); + const int iHand = jindex(base, "LeftHand"); + if (iShoulder < 0 || iArm < 0 || iHand < 0) GTEST_SKIP() << "no left-arm chain"; + + AutoRig::Marker shoulder; + shoulder.id = AutoRig::MarkerId::LeftShoulder; + shoulder.set = true; + shoulder.pos = {0.35, 1.60, 0.0}; + AutoRig::Marker wrist; + wrist.id = AutoRig::MarkerId::LeftWrist; + wrist.set = true; + wrist.pos = {1.30, 1.55, 0.10}; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {shoulder, wrist}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + + // Shoulder lands on its marker; the arm chain lays from THAT point, so + // LeftArm = lerp(shoulderMarker, wristMarker, 1/3). + EXPECT_LT(jdist(marked[iShoulder], AutoRig::Joint{"", -1, shoulder.pos}), 1e-6); + for (int k = 0; k < 3; ++k) { + const double arm13 = + shoulder.pos[k] + (wrist.pos[k] - shoulder.pos[k]) * (1.0 / 3.0); + EXPECT_LT(std::abs(marked[iArm].pos[k] - arm13), 1e-6) << "axis " << k; + } + EXPECT_LT(jdist(marked[iHand], AutoRig::Joint{"", -1, wrist.pos}), 1e-6); +} + +// ---- Inference: unmarked joints derived from marked neighbours ---------- + +TEST(AutoRigMarkers, ShoulderInferredFromHipsAndChinSpan) +{ + // Mark only chin + hips (no shoulders): each shoulder should be inferred + // ALONG the hips→head line (never above the head), not left at template. + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iLSh = jindex(base, "LeftShoulder"); + const int iHead = jindex(base, "Head"); + const int iHips = jindex(base, "Hips"); + if (iLSh < 0 || iHead < 0 || iHips < 0) GTEST_SKIP() << "no spine/shoulder"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; hips.set = true; hips.pos = {0.0, 0.80, 0.0}; + AutoRig::Marker chin; + chin.id = AutoRig::MarkerId::Chin; chin.set = true; chin.pos = {0.0, 2.00, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {hips, chin}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + // Shoulder up-coord lies strictly between hips and head (axis 1 = +Y). + EXPECT_GT(m[iLSh].pos[1], hips.pos[1]); + EXPECT_LT(m[iLSh].pos[1], chin.pos[1]); +} + +TEST(AutoRigMarkers, HipsInferredFromUpLegsWhenUnmarked) +{ + // Mark only the two up-legs (no hips): pelvis should land at their midpoint + // plus the template socket→pelvis rise — not at the template hips. + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + if (iHips < 0) GTEST_SKIP() << "no hips"; + + AutoRig::Marker lu, ru; + lu.id = AutoRig::MarkerId::LeftUpLeg; lu.set = true; lu.pos = {0.30, 0.70, 0.0}; + ru.id = AutoRig::MarkerId::RightUpLeg; ru.set = true; ru.pos = {-0.30, 0.70, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {lu, ru}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + // Pelvis centred between the sockets (x ≈ 0) and lifted above them (y > 0.70). + EXPECT_LT(std::abs(m[iHips].pos[0] - 0.0), 1e-6); + EXPECT_GT(m[iHips].pos[1], 0.70); +} + +TEST(AutoRigMarkers, UnmarkedShoulderMirrorsMarkedOne) +{ + // Mark one shoulder; the other should mirror across the body (opposite + // side-axis sign, ~symmetric), not stay at the template. + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iLSh = jindex(base, "LeftShoulder"); + const int iRSh = jindex(base, "RightShoulder"); + if (iLSh < 0 || iRSh < 0) GTEST_SKIP() << "no shoulders"; + + AutoRig::Marker ls; + ls.id = AutoRig::MarkerId::LeftShoulder; ls.set = true; ls.pos = {0.55, 1.50, 0.10}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {ls}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + EXPECT_LT(jdist(m[iLSh], AutoRig::Joint{"", -1, ls.pos}), 1e-6); + // Right shoulder is on the opposite side (x sign flipped relative to L). + EXPECT_LT(m[iRSh].pos[0], 0.0); + // Same height + depth as the marked one (pure mirror across the side axis). + EXPECT_LT(std::abs(m[iRSh].pos[1] - ls.pos[1]), 1e-6); +} + +TEST(AutoRigMarkers, ShoulderMarkedWristSkippedStillLaysArm) +{ + // Shoulder marked, wrist skipped: the hand should reach out from the marked + // shoulder by the template arm vector (not collapse onto the shoulder). + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iSh = jindex(base, "LeftShoulder"); + const int iHand = jindex(base, "LeftHand"); + if (iSh < 0 || iHand < 0) GTEST_SKIP() << "no left arm"; + const double tArmLen = std::sqrt( + std::pow(base[iHand].pos[0]-base[iSh].pos[0],2) + + std::pow(base[iHand].pos[1]-base[iSh].pos[1],2) + + std::pow(base[iHand].pos[2]-base[iSh].pos[2],2)); + + AutoRig::Marker ls; + ls.id = AutoRig::MarkerId::LeftShoulder; ls.set = true; ls.pos = {0.60, 1.55, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {ls}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + EXPECT_LT(jdist(m[iSh], AutoRig::Joint{"", -1, ls.pos}), 1e-6); + // Hand is ~one template arm-length away from the marked shoulder. + const double handLen = std::sqrt( + std::pow(m[iHand].pos[0]-ls.pos[0],2) + + std::pow(m[iHand].pos[1]-ls.pos[1],2) + + std::pow(m[iHand].pos[2]-ls.pos[2],2)); + EXPECT_GT(handLen, tArmLen * 0.5); +} + +TEST(AutoRigMarkers, UpLegSetKneeSkippedClampsFootToMeshFloor) +{ + // Up-leg marked, knee skipped: the foot must land at (not below) the mesh + // floor, and the knee must sit between the up-leg and the foot. Previously + // the template thigh-vector extrapolation pushed the foot past the mesh. + auto cloud = uprightCloud(); // y in [0, 2] → floor = 0 + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iUp = jindex(base, "LeftUpLeg"); + const int iKnee = jindex(base, "LeftLeg"); + const int iFoot = jindex(base, "LeftFoot"); + if (iUp < 0 || iKnee < 0 || iFoot < 0) GTEST_SKIP() << "no left leg"; + + AutoRig::Marker up; + up.id = AutoRig::MarkerId::LeftUpLeg; up.set = true; up.pos = {0.30, 0.90, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {up}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + + const double floor = 0.0; + // Foot sits on (not below) the mesh floor. + EXPECT_GE(m[iFoot].pos[1], floor - 1e-6); + EXPECT_LT(std::abs(m[iFoot].pos[1] - floor), 1e-6); + // Knee strictly between the up-leg (0.90) and the foot (0.0) in height. + EXPECT_LT(m[iKnee].pos[1], m[iUp].pos[1]); + EXPECT_GT(m[iKnee].pos[1], m[iFoot].pos[1]); +} diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index c1511bfaa..3517bb633 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 4a01e287a..7a2eee40c 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 000000000..85344a997 --- /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 3d5646a0a..328be955e 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -75,6 +75,7 @@ commands/NodeAnimCommands.cpp commands/PoseLibraryCommands.cpp commands/SkeletonResolver.cpp commands/ComputeSkinWeightsCommand.cpp +commands/AutoRigCommand.cpp BoneDragRelease.cpp PropertiesPanelController.cpp SceneTreeModel.cpp @@ -90,6 +91,8 @@ QuadRetopo.cpp QuadRetopoController.cpp SkinWeights.cpp SkinWeightsController.cpp +AutoRig.cpp +AutoRigController.cpp MeshDepthRenderer.cpp MultiViewTextureBaker.cpp TextureChannelPacker.cpp @@ -229,6 +232,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 5d8c88179..77db11c48 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 e47413afe..b4faa8fff 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/Manager.cpp b/src/Manager.cpp index 80d1804a3..3c2cf2de7 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -969,19 +969,46 @@ void Manager::loadResources() { for (const auto& [typeName, archName] : settings) { + QString archPath = QString::fromStdString(archName); + if (QDir::isAbsolutePath(archPath)) { + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + archPath.toStdString(), typeName, secName); + continue; + } + // Resolve relative paths against the application directory so that // resources are found regardless of the current working directory - // (e.g., when launched from an installed .deb package). - QString archPath = QString::fromStdString(archName); - if (!QDir::isAbsolutePath(archPath)) { + // (installed .deb, .app bundle, dev build). Build candidate roots + // and pick the first that actually contains the path. + // + // On macOS the media/cfg tree lives under Contents/MacOS/ (== + // applicationDirPath()), NOT at the .app bundle root. The previous + // code resolved relative paths against macBundlePath() (the bundle + // root), so in an installed .app every relative resource location — + // including the RTSS GLSL programs and material textures — pointed + // at a non-existent .app/media/... directory. Ogre then loaded + // no shaders/textures and every mesh rendered flat WHITE. Resolving + // against applicationDirPath() first fixes it; the bundle-root path + // stays as a fallback for any older layout. (#bug: white models in + // Homebrew/installed builds, all platforms.) + QStringList roots; + roots << file; // applicationDirPath() #if OGRE_PLATFORM == OGRE_PLATFORM_APPLE - archPath = QString::fromStdString(macBundlePath()) + "/" + archPath; -#else - archPath = file + "/" + archPath; + roots << QString::fromStdString(macBundlePath()); // .app bundle root (legacy) #endif + QString resolved; + for (const QString& root : roots) { + const QString cand = root + "/" + archPath; + if (QFileInfo::exists(cand)) { resolved = cand; break; } } + // If none exist (e.g. an optional location), fall back to the first + // candidate so Ogre logs a clear "resource location not found" for it + // rather than silently skipping. + if (resolved.isEmpty()) + resolved = roots.first() + "/" + archPath; + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( - archPath.toStdString(), typeName, secName); + resolved.toStdString(), typeName, secName); } } diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index cf3042df0..3ab54b543 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1669,13 +1669,44 @@ QString cloudProjectCacheRoot(const QString& localPath) return normalized.left(cloudIdx + marker.size() + slugEnd); } -void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile) +QStringList MeshImporterExporter::textureSearchRootsForImportFile(const QString& localPath) { - const QFileInfo fileInfo(localMainFile); - registerImportResourceDirectory(fileInfo.absolutePath()); + QStringList roots; + const QFileInfo fileInfo(localPath); + if (!fileInfo.exists()) + return roots; + + roots << fileInfo.absolutePath(); const QString cloudRoot = cloudProjectCacheRoot(fileInfo.absoluteFilePath()); if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath()) - registerImportResourceDirectory(cloudRoot); + roots << cloudRoot; + return roots; +} + +QStringList MeshImporterExporter::textureSearchRootsForEntity(const Ogre::Entity* entity) +{ + if (!entity) + return {}; + const Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) + return {}; + const Ogre::Any& any = mesh->getUserObjectBindings().getUserAny("qtme.source_path"); + if (!any.has_value()) + return {}; + try { + const std::string sourcePath = Ogre::any_cast(any); + if (sourcePath.empty()) + return {}; + return textureSearchRootsForImportFile(QString::fromStdString(sourcePath)); + } catch (const Ogre::Exception&) { + return {}; + } +} + +void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile) +{ + for (const QString& root : textureSearchRootsForImportFile(localMainFile)) + registerImportResourceDirectory(root); } /** @return true if at least one declared material exists in the manager for this group. */ diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index 521b60f3e..0c79e3f15 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -91,6 +91,12 @@ class MeshImporterExporter /// Register cloud cache paths before importing a downloaded project file. static void prepareCloudCachedImport(const QString& localMainFile); + /// Directories to search for sidecar textures after import (file dir + cloud cache root). + static QStringList textureSearchRootsForImportFile(const QString& localPath); + + /// Texture search roots for an entity from its mesh `qtme.source_path` binding. + static QStringList textureSearchRootsForEntity(const Ogre::Entity* entity); + /// Recompile RTSS materials and force SubEntity technique refresh (post-import). static void rebindEntityMaterials(Ogre::Entity* entity, const QStringList& textureSearchRoots = {}); diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 4811cdca4..e5e9f9528 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -732,6 +732,31 @@ bool PropertiesPanelController::hasAnimations() const return false; } +QVariantList PropertiesPanelController::skeletonData() const +{ + QVariantList result; + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (!ent || !ent->hasSkeleton()) continue; // skeleton viz, no anim gate + + QVariantMap entry; + entry["entity"] = QString::fromStdString(ent->getName()); + entry["showSkeleton"] = mAnimationWidget ? mAnimationWidget->isSkeletonDebugActive(ent) : false; + entry["showWeights"] = mAnimationWidget ? mAnimationWidget->isBoneWeightsShown(ent) : false; + result.append(entry); + } + return result; +} + +bool PropertiesPanelController::hasSkeletonSelection() const +{ + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + if (ent && ent->hasSkeleton()) return true; + return false; +} + QVariantList PropertiesPanelController::animationData() const { QVariantList result; diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index b4dfccfad..9b97cd883 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -215,6 +215,18 @@ class PropertiesPanelController : public QObject Q_INVOKABLE bool reparentNode(const QString& nodeName, const QString& newParentName); void setAnimationWidget(class AnimationWidget* widget) { mAnimationWidget = widget; } + // Skeleton (bone/skeleton viz — independent of animation clips). + // Returns one entry per selected entity that HAS a skeleton, regardless of + // whether it has any animation states. Each entry: { entity, showSkeleton, + // showWeights }. This is the data behind the "Skeleton" inspector section, + // which must surface for skinned-but-non-animated meshes (e.g. a freshly + // auto-rigged static mesh) — unlike animationData() which skips entities + // with no animation clips. + Q_INVOKABLE QVariantList skeletonData() const; + /// True when the first resolved selection has a skeleton. Drives the + /// "Skeleton" section's visibility. + Q_INVOKABLE bool hasSkeletonSelection() const; + // Animation Q_INVOKABLE QVariantList animationData() const; // grouped per entity Q_INVOKABLE void toggleAnimationEnabled(const QString& entityName, const QString& animName, bool enabled); diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index f8530fd9f..cb4dcd3a9 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -159,6 +159,103 @@ class TexturePaintMaskActionCommand : public QUndoCommand bool m_skipFirstRedo = true; }; +Ogre::TexturePtr findTextureAcrossGroups(const std::string& name) +{ + auto texPtr = Ogre::TextureManager::getSingleton().getByName(name); + if (texPtr) + return texPtr; + auto it = Ogre::TextureManager::getSingleton().getResourceIterator(); + while (it.hasMoreElements()) { + const Ogre::ResourcePtr r = it.getNext(); + if (r && r->getName() == name) + return Ogre::static_pointer_cast(r); + } + return {}; +} + +bool copyQImageToPaintBuffer(TexturePaintBuffer& buffer, const QImage& source) +{ + QImage qimg = source; + if (qimg.isNull()) + return false; + if (qimg.format() != QImage::Format_RGBA8888) + qimg = qimg.convertToFormat(QImage::Format_RGBA8888); + const int w = qimg.width(); + const int h = qimg.height(); + if (w <= 0 || h <= 0) + return false; + buffer.resize(w, h); + for (int y = 0; y < h; ++y) { + std::memcpy(buffer.data().data() + static_cast(y) * static_cast(w) * 4u, + qimg.constScanLine(y), + static_cast(w) * 4u); + } + buffer.clearDirty(); + return true; +} + +bool loadPaintBufferFromImageBytes(TexturePaintBuffer& buffer, + const uint8_t* data, + std::size_t size) +{ + QImage qimg; + if (!qimg.loadFromData(data, static_cast(size))) + return false; + return copyQImageToPaintBuffer(buffer, qimg); +} + +bool loadPaintBufferFromDiskPath(TexturePaintBuffer& buffer, const QString& path) +{ + if (path.isEmpty() || !QFileInfo::exists(path)) + return false; + return copyQImageToPaintBuffer(buffer, QImage(path)); +} + +// CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture. +// GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures. +bool loadPaintBufferFromNonGpuSources(TexturePaintBuffer& buffer, + const Ogre::TexturePtr& texPtr, + const QString& texName) +{ + if (texName.isEmpty()) + return false; + + if (texPtr) { + const QString origin = QString::fromStdString(texPtr->getOrigin()); + if (!origin.isEmpty() && loadPaintBufferFromDiskPath(buffer, origin)) + return true; + + const QString group = QString::fromStdString(texPtr->getGroup()); + if (!group.isEmpty()) { + if (loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + texName)) + return true; + if (!origin.isEmpty() + && loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + origin)) { + return true; + } + } + } + + const std::vector bytes = + EmbeddedTextureCache::retrieve(texName.toStdString()); + if (!bytes.empty() + && loadPaintBufferFromImageBytes(buffer, bytes.data(), bytes.size())) { + return true; + } + + const QString baseName = QFileInfo(texName).fileName(); + if (baseName != texName) { + const std::vector baseBytes = + EmbeddedTextureCache::retrieve(baseName.toStdString()); + if (!baseBytes.empty() + && loadPaintBufferFromImageBytes(buffer, baseBytes.data(), baseBytes.size())) { + return true; + } + } + + return loadPaintBufferFromDiskPath(buffer, texName); +} + } // namespace TexturePaintController* TexturePaintController::instance() @@ -539,9 +636,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) Ogre::TexturePtr originalTex; if (!existingTex.isEmpty()) { try { - originalTex = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + originalTex = findTextureAcrossGroups(existingTex.toStdString()); } catch (...) {} } m_originalTexture = originalTex; @@ -551,16 +646,22 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) // come from inline FBX embeds (no disk file), legacy on-disk // files, or auto-generated render targets. Each strategy // succeeds for a different source. - // + Ogre::TexturePtr existing = originalTex; + if (!existing) { + try { + existing = findTextureAcrossGroups(existingTex.toStdString()); + } catch (...) {} + } + + // 0. CPU-side: embedded FBX bytes, on-disk origin, resource-group path. + if (loadPaintBufferFromNonGpuSources(m_buffer, existing, existingTex)) { + loadedExisting = true; + loadError.clear(); + } + // 1. TextureManager → convertToImage (works when Ogre keeps // pixels in an Image buffer beside the GPU upload). - Ogre::TexturePtr existing; - try { - existing = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); - } catch (...) {} - if (existing) { + if (!loadedExisting && existing) { try { if (!existing->isLoaded()) existing->load(); Ogre::Image img; @@ -581,7 +682,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) } catch (...) { loadError = QStringLiteral("convertToImage exception"); } - } else { + } else if (!loadedExisting && !existing) { loadError = QStringLiteral("texture not found in TextureManager"); } diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index 9f3c86281..a7dac21f7 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -25,6 +25,7 @@ #include "commands/BoneTransformCommand.h" #include "BoneDragRelease.h" #include "EditModeController.h" +#include "AutoRigController.h" #include "TexturePaintController.h" #include "AnimationControlController.h" #include "PropertiesPanelController.h" @@ -994,6 +995,16 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) { if (e->button()==Qt::LeftButton) { + // Auto-rig marker placement (Mixamo-style) is active: left-click drops + // the next marker on the mesh surface. Highest priority — like the + // knife session, nothing else (selection/transform) fires while placing + // markers. Dismissed via the dialog's Cancel/Commit. + if (AutoRigController::instance()->markerMode()) + { + AutoRigController::instance()->handleMarkerClick(m_pActiveWidget, e->pos()); + return; + } + auto* editCtrl = EditModeController::instance(); // Knife session is active: left-click adds a cut point at the diff --git a/src/commands/AutoRigCommand.cpp b/src/commands/AutoRigCommand.cpp new file mode 100644 index 000000000..686cf030b --- /dev/null +++ b/src/commands/AutoRigCommand.cpp @@ -0,0 +1,68 @@ +#include "commands/AutoRigCommand.h" +#include "Manager.h" +#include "SkinWeights.h" +#include "AutoRigController.h" + +#include +#include + +AutoRigCommand::AutoRigCommand(std::string entityName, + AutoRig::Options opts, + std::vector markers, + bool alsoSkin, + QUndoCommand* parent) + : QUndoCommand(parent) + , mEntityName(std::move(entityName)) + , mOpts(opts) + , mMarkers(std::move(markers)) + , mAlsoSkin(alsoSkin) +{ + setText(mMarkers.empty() ? QStringLiteral("Auto-Rig") + : QStringLiteral("Auto-Rig from Markers")); +} + +Ogre::Entity* AutoRigCommand::resolveEntity() const +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return nullptr; + for (Ogre::Entity* e : mgr->getEntities()) { + if (e && e->getMovableType() == "Entity" && e->getName() == mEntityName) + return e; + } + return nullptr; +} + +void AutoRigCommand::redo() +{ + Ogre::Entity* entity = resolveEntity(); + if (!entity) { + mReport.applied = false; + mReport.error = QStringLiteral("Entity no longer in scene."); + return; + } + + // unrigEntity (run on undo) leaves a clean static mesh, so re-running the + // rig on a redo is idempotent — no special first-vs-replay handling needed. + mSkinned = false; + mReport = AutoRig::rigEntityWithMarkers(entity, mMarkers, mOpts); + if (mReport.applied && mAlsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + mSkinned = sw.applied; + if (!sw.applied) + mReport.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } + if (mReport.applied) + AutoRigController::instance()->notifyRiggingChanged(mEntityName); +} + +void AutoRigCommand::undo() +{ + if (!mReport.applied) return; // nothing was attached + Ogre::Entity* entity = resolveEntity(); + if (!entity) return; + // Drop any skeleton-debug overlay BEFORE detaching the skeleton (it would + // otherwise dangle), then revert to a static mesh. + AutoRigController::instance()->notifyRiggingChanged(mEntityName); + AutoRig::unrigEntity(entity); +} diff --git a/src/commands/AutoRigCommand.h b/src/commands/AutoRigCommand.h new file mode 100644 index 000000000..940dc1980 --- /dev/null +++ b/src/commands/AutoRigCommand.h @@ -0,0 +1,61 @@ +#ifndef AUTO_RIG_COMMAND_H +#define AUTO_RIG_COMMAND_H + +#include +#include + +#include +#include + +#include "AutoRig.h" + +namespace Ogre { class Entity; } + +/** + * Undoable wrapper around `AutoRig::rigEntity[WithMarkers]` (+ optional + * `SkinWeights::computeAndApply`) — issue #407 follow-up. + * + * Auto-rig only ever runs on a STATIC (skeleton-less) mesh, so the undo is + * unambiguous: strip the freshly-attached skeleton and revert the entity to + * its static form (`AutoRig::unrigEntity`). There is no prior skeleton/weights + * to snapshot — the "before" state is simply "no skeleton". + * + * `redo()` runs the rig on its first invocation (and again on later redos — + * `unrigEntity` leaves a clean static mesh, so re-rigging is idempotent); + * `undo()` strips the rig. When `alsoSkin` is set, the skin pass runs inside + * the same command (not as a child) so a single Ctrl+Z reverts rig + skin + * together. The captured report lets the controller surface bone/marker counts + * to the UI after pushing the command. + * + * Targets the entity by name so it survives scene rebuilds, like the other + * entity-scoped commands. + */ +class AutoRigCommand : public QUndoCommand +{ +public: + AutoRigCommand(std::string entityName, + AutoRig::Options opts, + std::vector markers, + bool alsoSkin, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + const AutoRig::Report& report() const { return mReport; } + bool applied() const { return mReport.applied; } + bool skinned() const { return mSkinned; } + +private: + Ogre::Entity* resolveEntity() const; + + std::string mEntityName; + AutoRig::Options mOpts; + std::vector mMarkers; + bool mAlsoSkin = false; + + AutoRig::Report mReport; + bool mSkinned = false; +}; + +#endif // AUTO_RIG_COMMAND_H diff --git a/src/commands/AutoRigCommand_test.cpp b/src/commands/AutoRigCommand_test.cpp new file mode 100644 index 000000000..41915620f --- /dev/null +++ b/src/commands/AutoRigCommand_test.cpp @@ -0,0 +1,101 @@ +#include + +#include + +#include "commands/AutoRigCommand.h" +#include "AutoRig.h" +#include "Manager.h" + +// These tests exercise the no-Ogre / error-report branches of AutoRigCommand +// (the ones that need NO scene and NO display): +// +// * ctor / setText contract (plain "Auto-Rig" vs "Auto-Rig from Markers"), +// * report()/applied()/skinned() accessors before redo(), +// * redo() against an unresolvable entity name → error branch (applied==false), +// * undo() before any successful redo → strict no-op (guarded on applied). +// +// resolveEntity() returns nullptr when Manager::getSingletonPtr() is null OR no +// entity matches, so a bogus name reliably drives the error branch. The actual +// rig + skin attach/detach round-trip needs a real mesh and is covered by an +// Ogre-gated layer on CI. + +namespace { +const std::string kBogusEntity = + "__qtmesh_nonexistent_entity_for_autorig_test__"; + +AutoRig::Options humanoidOpts() { + AutoRig::Options o; + o.tmpl = AutoRig::Template::Humanoid; + o.upAxis = 1; + return o; +} +} // namespace + +// ---- ctor / text() ------------------------------------------------------- + +TEST(AutoRigCommandTest, CtorSetsPlainTextWithoutMarkers) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, /*alsoSkin=*/false); + EXPECT_EQ(cmd.text(), QStringLiteral("Auto-Rig")); +} + +TEST(AutoRigCommandTest, CtorSetsMarkerTextWithMarkers) { + AutoRig::Marker m; + m.id = AutoRig::MarkerId::Hips; + m.set = true; + m.pos = {0, 0, 0}; + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {m}, /*alsoSkin=*/true); + EXPECT_EQ(cmd.text(), QStringLiteral("Auto-Rig from Markers")); +} + +// ---- initial accessor state ---------------------------------------------- + +TEST(AutoRigCommandTest, ReportInitiallyNotApplied) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + EXPECT_FALSE(cmd.applied()); + EXPECT_FALSE(cmd.report().applied); + EXPECT_FALSE(cmd.skinned()); + EXPECT_TRUE(cmd.report().error.isEmpty()); + EXPECT_EQ(cmd.report().boneCount, 0); + EXPECT_EQ(cmd.report().markersApplied, 0); +} + +// ---- redo() on an unresolvable entity → error branch --------------------- + +TEST(AutoRigCommandTest, RedoOnBogusEntitySetsErrorReport) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + cmd.redo(); + EXPECT_FALSE(cmd.applied()); + EXPECT_EQ(cmd.report().error, QStringLiteral("Entity no longer in scene.")); +} + +TEST(AutoRigCommandTest, RedoWithNoManagerSingleton) { + Manager::kill(); + ASSERT_EQ(Manager::getSingletonPtr(), nullptr); + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, true); + cmd.redo(); + EXPECT_FALSE(cmd.applied()); + EXPECT_FALSE(cmd.skinned()); + EXPECT_EQ(cmd.report().error, QStringLiteral("Entity no longer in scene.")); +} + +TEST(AutoRigCommandTest, RedoOnBogusEntityIsIdempotent) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + cmd.redo(); + cmd.redo(); + EXPECT_FALSE(cmd.applied()); + EXPECT_EQ(cmd.report().error, QStringLiteral("Entity no longer in scene.")); +} + +// ---- undo() before a successful redo → no-op ----------------------------- + +TEST(AutoRigCommandTest, UndoBeforeApplyIsNoOp) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + // applied is false → undo() must early-return without touching anything. + EXPECT_NO_FATAL_FAILURE(cmd.undo()); + EXPECT_FALSE(cmd.applied()); + + // Same after a failed redo (still not applied). + cmd.redo(); + EXPECT_NO_FATAL_FAILURE(cmd.undo()); + EXPECT_FALSE(cmd.applied()); +} diff --git a/src/main.cpp b/src/main.cpp index 4ed96da51..5bcb28a51 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include "mainwindow.h" #include "MaterialEditorQML.h" #include "QMLMaterialHighlighter.h" @@ -141,6 +142,21 @@ int main(int argc, char *argv[]) // This prevents issues with native macOS style not supporting customization QQuickStyle::setStyle("Basic"); + // Force the Qt Quick *software* scene-graph backend BEFORE QApplication. + // Every QML surface (the Inspector / Context / Material QQuickWidgets in + // their docks, the ViewCube, etc.) runs software-rendered to avoid GL/Metal + // conflicts with Ogre's direct-to-native rendering. The MainWindow ctor used + // to set this, but by then Qt has already probed and locked the default RHI + // (Metal/GL) on first QQuickWidget init — too late. In a deployed .app the + // embedded dock QQuickWidgets then fail to composite and render BLANK WHITE + // (issue: Homebrew build shows white Inspector/Context panels) while a + // dev-SDK run happened to still paint. `QSGRendererInterface::setGraphicsApi` + // / `QSG_RHI_BACKEND` only take effect if set before the scene graph + // initialises, so they belong here, ahead of QApplication. + qputenv("QSG_RHI_BACKEND", "software"); + qputenv("QT_QUICK_BACKEND", "software"); + QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); + QApplication a(argc, argv); // Capture qDebug/qWarning/etc. from the rest of startup into the in-app console diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 05f767f8a..f2974f060 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" @@ -595,10 +596,10 @@ void MainWindow::initToolBar() // QML Properties Panel (replaces old Transform tab with modern collapsible inspector) { - // Force software rendering before creating any QQuickWidget to avoid GL conflicts with Ogre - qputenv("QSG_RHI_BACKEND", "software"); - qputenv("QT_QUICK_BACKEND", "software"); - QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); + // NOTE: the Qt Quick *software* scene-graph backend is forced in main() + // BEFORE QApplication (QSG_RHI_BACKEND / setGraphicsApi only take effect + // before the scene graph initialises). Setting it here was too late and + // left deployed-bundle dock QQuickWidgets rendering blank white. registerEditorModeQmlSingletons(); m_propertiesPanel = new QQuickWidget(); @@ -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", @@ -3879,26 +3885,9 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) continue; - QStringList textureRoots; - textureRoots << fileInfo.absolutePath(); - const QString normalized = QDir::fromNativeSeparators(fileInfo.absoluteFilePath()); - const QString marker = QStringLiteral("/cloud/"); - const int cloudIdx = normalized.indexOf(marker); - if (cloudIdx >= 0) { - const QString tail = normalized.mid(cloudIdx + marker.size()); - const int ownerEnd = tail.indexOf(QLatin1Char('/')); - if (ownerEnd > 0) { - const int slugEnd = tail.indexOf(QLatin1Char('/'), ownerEnd + 1); - const QString cloudRoot = slugEnd < 0 - ? normalized - : normalized.left(cloudIdx + marker.size() + slugEnd); - if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath()) - textureRoots << cloudRoot; - } - } - auto* entity = static_cast(obj); - MeshImporterExporter::rebindEntityMaterials(entity, textureRoots); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForImportFile(localMainFile)); } SpaceCamera* cam = nullptr; @@ -3914,15 +3903,14 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) cam->frameSelection(); QTimer::singleShot(0, this, [this, localMainFile, entityNamesBefore]() { - const QFileInfo fileInfo(localMainFile); + const QStringList textureRoots = + MeshImporterExporter::textureSearchRootsForImportFile(localMainFile); for (auto* obj : Manager::getSingleton()->getEntities()) { if (!obj || obj->getMovableType() != QLatin1String("Entity")) continue; if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) continue; - QStringList textureRoots; - textureRoots << fileInfo.absolutePath(); MeshImporterExporter::rebindEntityMaterials(static_cast(obj), textureRoots); } @@ -3939,6 +3927,12 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) void MainWindow::importMeshs(const QStringList &_uriList) { + QSet entityNamesBefore; + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == QLatin1String("Entity")) + entityNamesBefore.insert(QString::fromStdString(obj->getName())); + } + auto txn = SentryReporter::startTransaction("ui.import", "file.import"); QList animOnlySkeletons; try { @@ -3949,6 +3943,39 @@ void MainWindow::importMeshs(const QStringList &_uriList) } SentryReporter::finishTransaction(txn); + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (!obj || obj->getMovableType() != QLatin1String("Entity")) + continue; + if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) + continue; + + auto* entity = static_cast(obj); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForEntity(entity)); + } + + QTimer::singleShot(0, this, [this, entityNamesBefore]() { + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (!obj || obj->getMovableType() != QLatin1String("Entity")) + continue; + if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) + continue; + + auto* entity = static_cast(obj); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForEntity(entity)); + } + + if (m_pRoot && m_pRoot->getRenderSystem()) { + try { + m_pRoot->renderOneFrame(); + } catch (...) { + } + } + for (EditorViewport* vp : mDockWidgetList) + vp->getOgreWidget()->update(); + }); + // Handle animation-only files: show a notification and offer an immediate merge // if a compatible entity is already selected. for (const Ogre::SkeletonPtr& skel : animOnlySkeletons) { diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 420dfee97..1f99f5325 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2778fdb6a..6fbac4df6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -120,6 +120,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/PoseLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/PoseLibraryCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp @@ -137,6 +138,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 diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index 4ebd0abfe..057686d2b 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.0'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.2'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000;