Skip to content

feat(skin): inverse-distance auto skin weights (closes #402)#699

Open
fernandotonon wants to merge 1 commit into
masterfrom
feat/ai-assist-skin-weights
Open

feat(skin): inverse-distance auto skin weights (closes #402)#699
fernandotonon wants to merge 1 commit into
masterfrom
feat/ai-assist-skin-weights

Conversation

@fernandotonon
Copy link
Copy Markdown
Owner

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:

  • 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) — Mixamo FBX 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 distance from v to the bone's segment (line from head to average of children, falling back to point distance for leaves).
  2. If dist > maxInfluenceDistance × mesh-diagonal, skip bone (prevents a finger 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 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 BBW's volumetric smoothness — 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.
  • GUI: Edit Mode → Tools → "Compute Skin Weights…" button + top-level dialog (qml/SkinWeightsDialog.qml) driven by the SkinWeightsController QML singleton. Button binds to hasSkinnedSelection so it disables on static meshes.
  • 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.

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.

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 respected
  • max_distance cap excludes far bones
  • Higher falloff sharpens the bind toward the nearest bone
  • Empty input returns false
  • Algorithm string round-trip

Test plan

  • qtmesh skin <file> -o <out> produces valid skinned output
  • --max-influences N clamps assignments per vertex (verified: 5828 verts × 2 = 10,596 assignments with --max-influences 2)
  • Invalid numeric flags rejected with clear error
  • --json emits structured output
  • Multi-entity input rejected fail-fast
  • GUI button binds to skinned selection only
  • CI green on Linux / macOS / Windows
  • CodeRabbit / Sonar review

🤖 Generated with Claude Code

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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Warning

Review limit reached

@fernandotonon, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d35a49c-e427-4995-b949-2c02e7b5d3ce

📥 Commits

Reviewing files that changed from the base of the PR and between 03e6067 and 8939325.

📒 Files selected for processing (19)
  • CLAUDE.md
  • README.md
  • qml/PropertiesPanel.qml
  • qml/SkinWeightsDialog.qml
  • qml/qmldir
  • src/CLIPipeline.cpp
  • src/CLIPipeline.h
  • src/CMakeLists.txt
  • src/MCPServer.cpp
  • src/MCPServer.h
  • src/SkinWeights.cpp
  • src/SkinWeights.h
  • src/SkinWeightsController.cpp
  • src/SkinWeightsController.h
  • src/SkinWeights_test.cpp
  • src/main.cpp
  • src/mainwindow.cpp
  • src/qml_resources.qrc
  • tests/CMakeLists.txt
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ai-assist-skin-weights

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/SkinWeights.cpp
Comment on lines +306 to +310
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread src/SkinWeights.cpp
Comment on lines +295 to +297
if (opts.replaceExisting) {
sub->clearBoneAssignments();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI: libigl bounded biharmonic skinning weights

1 participant