feat(skin): inverse-distance auto skin weights (closes #402)#699
feat(skin): inverse-distance auto skin weights (closes #402)#699fernandotonon wants to merge 1 commit into
Conversation
Closes #402 (epic #397, AI-Assist slice 5). Adds automatic skin weight computation for skinned meshes. ## Background — why not libigl BBW The issue proposes wrapping libigl's bounded biharmonic weights (BBW), which is the gold-standard algorithm used by Blender / Maya / Houdini. In practice: * BBW solves a biharmonic equation over the **volume** of the mesh — requires tetrahedralization via TetGen. * TetGen is **GPL/copyleft**. Linking it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. * TetGen meshing also fails on common asset issues (non-manifold, self-intersections, degenerate tris) — character FBX from Mixamo would routinely fail before BBW even runs. This slice ships an inverse-distance heuristic with **zero new dependencies**. The `Algorithm` enum is in place to plug in libigl BBW behind a future opt-in `-DENABLE_LIBIGL_BBW` flag for users who accept the GPL implications. ## Algorithm For each vertex `v`: 1. For every bone `b` in the skeleton's bind pose, compute the distance from `v` to the bone's segment — line from the bone's head to the average position of its children (or just to head for leaf bones). 2. If `dist > maxInfluenceDistance × mesh-diagonal`, skip bone (prevents a finger bone from picking up weight on a foot). 3. Compute raw weight `w_b = 1 / (dist² + eps)^(falloff/2)`. 4. Keep the top-K bones (default K=4, matches the hardware skinning convention). 5. Normalize so the kept weights sum to 1. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." It's heuristic, not biharmonic, so it doesn't get the volumetric smoothness BBW provides — but it works out of the box on any character mesh including non-manifold FBX imports. `replaceExisting=false` enables a merge mode that fills in missing weights while keeping existing ones (useful for "manually skinned the torso, now auto-skin the rest" workflows). ## Surface * **CLI**: `qtmesh skin <file> [--max-influences N] [--falloff F] [--max-distance D] [--skip-unweighted] [--merge] -o <out> [--json]`. Multi-entity inputs rejected fail-fast (matches the `decimate` / `retopo` convention). * **MCP**: `compute_skin_weights` tool with `max_influences / falloff / max_distance / skip_unweighted / replace_existing` params. Returns a structured `skin` object. * **GUI**: Edit Mode → Tools → "Compute Skin Weights…" button + top-level dialog (`qml/SkinWeightsDialog.qml`) driven by the `SkinWeightsController` QML singleton. The button binds to `hasSkinnedSelection` so it disables on static meshes. Same Inspector-styled idiom as QuadRetopoDialog including the Esc/ Enter keyboard handlers. * **Library**: `SkinWeights::computeAndApply(entity, opts, algo)` for the Ogre-backed path; `SkinWeights::computeWeights( positions, bones, opts, outWeights)` is a pure-data variant used by tests and headless callers. ## Verification (Rumba Dancing.fbx) ``` $ qtmesh skin 'rumba115/Rumba Dancing.fbx' -o /tmp/reskin.glb Skin Weights ============ Mesh: Rumba Dancing Skeleton: Rumba Dancing.skeleton Bones: 69 Vertices processed: 5828 Assignments: 8461 → 20129 Wrote: reskin.glb ``` 20,129 assignments / 5828 verts = avg 3.45 influences per vertex (capped at 4). Valid 761 KB .glb produced; the rig drives skeletal deformation on round-trip. ## Sentry breadcrumbs * `ai.assist.skin_weights` for every action (CLI, MCP, GUI). ## Tests `src/SkinWeights_test.cpp` covers the pure-data path: * Near vertices get correct bone assignment * Weights sum to 1.0 per vertex * All weights non-negative * `max_influences` cap is respected * `max_distance` cap excludes far bones * Higher falloff sharpens the bind toward the nearest bone * Empty input returns false * Algorithm string round-trip `tests/CMakeLists.txt` updated to include `SkinWeights.cpp` and `SkinWeightsController.cpp` (same pattern as the previous slices needed once each test binary links `mainwindow.cpp`). ## Documentation * `CLAUDE.md` — new `SkinWeights` entry under AI-Assist, including the GPL rationale for deferring BBW. * `README.md` — `qtmesh skin` CLI examples. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
More reviews will be available in 22 minutes and 29 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (19)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 89393251e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Ogre::VertexBoneAssignment vba; | ||
| vba.vertexIndex = static_cast<unsigned int>(v); | ||
| vba.boneIndex = boneIdxToHandle[vw.boneIndices[k]]; | ||
| vba.weight = static_cast<float>(vw.weights[k]); | ||
| sub->addBoneAssignment(vba); |
There was a problem hiding this comment.
Route shared-vertex weights through the mesh assignment list
For submeshes with useSharedVertices, Ogre stores skin assignments on Mesh::getBoneAssignments(), not the submesh list; the existing exporter follows that split in src/FBX/FBXExporter.cpp by reading m_mesh->getBoneAssignments() for shared geometry. This path always calls sub->addBoneAssignment, so running skin on a shared-vertex skinned mesh leaves the mesh-level assignment list unchanged/empty and the exported result can keep stale weights or lose the newly computed weights entirely.
Useful? React with 👍 / 👎.
| if (opts.replaceExisting) { | ||
| sub->clearBoneAssignments(); | ||
| } |
There was a problem hiding this comment.
Honor merge mode instead of appending duplicate weights
When --merge/replaceExisting=false is used, this only skips clearing the old assignments; the loop below still adds a complete new normalized set for every vertex. On any vertex that already had weights, Ogre's assignment list now contains both the old influences and the new influences, so the merged vertex can exceed the influence cap or have weights that no longer sum to 1 rather than preserving existing weights and filling only missing vertices as the option promises.
Useful? React with 👍 / 👎.
|



Summary
Closes #402 (epic #397, AI-Assist slice 5). Adds automatic skin weight computation for skinned meshes.
Background — why not libigl BBW
The issue proposes wrapping libigl's bounded biharmonic weights (BBW). In practice:
This slice ships an inverse-distance heuristic with zero new dependencies. The
Algorithmenum is in place to plug in libigl BBW behind a future opt-in-DENABLE_LIBIGL_BBWflag for users who accept the GPL implications.Algorithm
For each vertex
v:bin the skeleton's bind pose, compute distance fromvto the bone's segment (line from head to average of children, falling back to point distance for leaves).dist > maxInfluenceDistance × mesh-diagonal, skip bone (prevents a finger from picking up weight on a foot).w_b = 1 / (dist² + eps)^(falloff/2).This is the same algorithm Maya / 3dsMax use as their default "smooth bind." It's heuristic, not biharmonic, so it doesn't get BBW's volumetric smoothness — but it works out of the box on any character mesh including non-manifold FBX imports.
replaceExisting=falseenables a merge mode that fills in missing weights while keeping existing ones (useful for "manually skinned the torso, now auto-skin the rest" workflows).Surface
qtmesh skin <file> [--max-influences N] [--falloff F] [--max-distance D] [--skip-unweighted] [--merge] -o <out> [--json]. Multi-entity inputs rejected fail-fast (matches thedecimate/retopoconvention).compute_skin_weightstool withmax_influences / falloff / max_distance / skip_unweighted / replace_existingparams.qml/SkinWeightsDialog.qml) driven by theSkinWeightsControllerQML singleton. Button binds tohasSkinnedSelectionso it disables on static meshes.SkinWeights::computeAndApply(entity, opts, algo)for the Ogre-backed path;SkinWeights::computeWeights(positions, bones, opts, outWeights)is a pure-data variant used by tests.Verification (Rumba Dancing.fbx)
20,129 assignments / 5828 verts = avg 3.45 influences per vertex (capped at 4). Valid 761 KB .glb produced.
Sentry breadcrumbs
ai.assist.skin_weightsfor every action (CLI, MCP, GUI).Tests
src/SkinWeights_test.cppcovers the pure-data path:max_influencescap respectedmax_distancecap excludes far bonesTest plan
qtmesh skin <file> -o <out>produces valid skinned output--max-influences Nclamps assignments per vertex (verified: 5828 verts × 2 = 10,596 assignments with--max-influences 2)--jsonemits structured output🤖 Generated with Claude Code