Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ __pycache__/
/monk_*.png
/multiview_bake_*.png
/multiview_bake_*i.png
!docs/SKINNING_QUALITY.md
4 changes: 3 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions THIRD_PARTY_AI_MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,33 @@ the binary). Attribution + licenses for the models and their training data:
`encoder.onnx` + `decoder.onnx` via ONNX Runtime (`src/UniRigPredictor.cpp`),
downloading them on first use to `AppData/ai_models/unirig/`.

### UniRig skinning head (issue #819 Slice C) — decision record

- **Status: not exported.** UniRig's second stage (skin-weight prediction,
the Bone-Point Cross Attention head) runs its mesh geometry through a
**PTv3 (Point Transformer V3) backbone built on spconv sparse
convolutions**, plus flash-attn MHA modules (verified against
`src/model/unirig_skin.py` upstream, 2026-07). spconv ops have **no ONNX
operator lowering** — the skeleton-stage export only worked because that
stage never executes the PTv3/spconv path (it was stubbed out). A faithful
`skin.onnx` therefore requires re-implementing the PTv3 forward densely, a
research task, not an export chore.
- **SkinTokens / TokenRig evaluated as the issue's decision gate asks**
(https://github.com/VAST-AI-Research/SkinTokens): code **MIT**; predicts
the full rig (skeleton + skinning) as one token sequence via an FSQ-CVAE
weight tokenizer + a Qwen3-0.6B autoregressive transformer — **no spconv**,
so it is ONNX-exportable with the same KV-cache decoder pattern as the
#408 skeleton export. It *is* the preferred ML-skinning path, but it is a
full new integration (its own weight tokenizer + AR decode + a different
skeleton representation), not a drop-in head on our predicted skeletons.
Weights-license confirmation on its HF release is part of that follow-up.
- **What ships today:** `SkinWeights::Algorithm::UniRigML` exists on every
surface (GUI/CLI/MCP) and falls back to GeodesicVoxel with a clear
`fallbackReason` — the fallback the issue specifies. The geodesic-voxel
default (#819 Slice A) covers the through-volume awareness the ML head
would have contributed; the ML path lights up in a follow-up slice via
SkinTokens without changing the app surfaces.

## TripoSR — image-to-3D mesh generation (epic #764)

- **Model:** TripoSR single-image 3D reconstruction (DINO ViT tokenizer +
Expand Down
107 changes: 107 additions & 0 deletions docs/SKINNING_QUALITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Skinning quality: metrics, acceptance suite, and the Mixamo comparison protocol

Issue [#819](https://github.com/fernandotonon/QtMeshEditor/issues/819) (Skinning v2, Slice E).
The algorithms these measure: **GeodesicVoxel** (default, Slice A),
**InverseDistance** (#402 legacy / fallback), the Slice-B post-passes,
and the **Dual-Quaternion display toggle** (Slice D).

## Metrics

All computed by `qtmesh skin <file> --evaluate [--json]` on the asset's
**existing** weights (whoever authored them), and by the pure-data
`SkinMetrics` / `SkinWeightsPost` APIs in the acceptance tests.

| Metric | Definition | Good looks like |
|---|---|---|
| Influence histogram | vertices per influence count (0–8), average, max | max ≤ 4 (hardware-skinning convention) |
| Weight smoothness | Laplacian energy: mean over mesh edges of ‖w_u − w_v‖² | lower = smoother falloffs; hard 0/1 borders score ~2 per edge |
| Bleed fraction | share of committed (vertex, bone) weights whose bone is **not geodesically local** to the vertex (GeodesicVoxelBind field) | 0 — geodesic-voxel weights are 0 by construction |
Comment on lines +16 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reconcile the bleed metric definition with the sample output.

The table says GeodesicVoxel bleed is 0 “by construction,” but the documented GeodesicVoxel sample reports 0.0055. Clarify whether post-processing/smoothing can introduce non-local weights, then align the definition, acceptance threshold, and sample output.

Also applies to: 59-60

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/SKINNING_QUALITY.md` around lines 16 - 18, The documented bleed metric
contradicts the GeodesicVoxel sample output. Update the “Bleed fraction”
definition and related acceptance threshold to state whether post-processing or
smoothing can create non-local weights, then revise the GeodesicVoxel sample
value or explanation so the definition, threshold, and sample output are
consistent.

| Volume preservation | LBS-deformed / rest mesh volume under a specified joint rotation (`SkinMetrics::deformLBS` + `meshVolume`) | ≥ 0.9 at a 90° single-joint bend |

## Acceptance suite (CI)

Procedural, hermetic fixtures in `src/SkinMetrics_test.cpp` /
`src/GeodesicVoxelBind_test.cpp` (no binary assets, no downloads):

- **Two-limb proximity pair** (boxes 0.5 units apart, one bone each):
geodesic-voxel bleed = **0** (asserted exactly); the inverse-distance
contrast on the same fixture must show > 0.1 bleed — if it stops
bleeding the fixture has lost its meaning.
- **U-shape bend-not-chord**: weights follow the geodesic path, never
the Euclidean chord between arm tips.
- **Cracked (non-watertight) box**: weights match the closed box —
voxel-scale hole closing.
- **90° elbow bend** (capsule tube, radius 0.5 × length 4, two bones,
smoothing 8): volume ratio ≥ **0.9**. Reference run: **0.911**
(rest 2.828, bent 2.577). For twists beyond what any weight map can
fix, use the Dual-Quaternion display toggle (Slice D).
- **Smoothing property**: Laplacian relaxation strictly reduces the
weight-field energy.

## Evaluating an asset

```bash
qtmesh skin character.fbx --evaluate # text report
qtmesh skin character.fbx --evaluate --json # machine-readable
qtmesh skin character.fbx --evaluate --voxel-res 128 # finer bleed field
```

Sample output (bandit.fbx, 90k verts, 119 bones, skinned by
`qtmesh skin --algo geodesic-voxel`):

```
Skin Evaluation
===============
Vertices: 90573
Bones: 119
Avg influences: 3.47
Max influences: 4
Smoothness energy: 0.004... (lower = smoother falloffs)
Bleed fraction: 0.0055 (weights not geodesically local)
```
Comment on lines +52 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language identifier to the sample-output fence.

The unlabelled fence triggers MD040. Use ```text for this report block.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 52-52: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/SKINNING_QUALITY.md` around lines 52 - 61, Add the text language
identifier to the fenced sample-output block in the Skin Evaluation section by
changing the opening fence to ```text, while leaving the report contents
unchanged.

Source: Linters/SAST tools


## Mixamo comparison protocol (manual — no Adobe assets in-repo)

Adobe's Mixamo auto-rigger is the industry reference for one-click
skinning. The protocol compares our weights against it on the same
mesh without committing any Adobe-derived asset:

1. Take a CC0 humanoid (e.g. a [Quaternius](https://quaternius.com)
character), export it **unrigged** as FBX.
2. Upload to [mixamo.com](https://www.mixamo.com), auto-rig with the
default skeleton (no animation), download as FBX Binary, T-pose.
Keep the file local — it is Adobe-processed and must not be
committed.
3. Rig + skin the same unrigged mesh with ours:
`qtmesh rig model.fbx --skeleton humanoid --skin -o ours.fbx`
(or `qtmesh skin` if you already have a matching skeleton).
4. Compare: `qtmesh skin ours.fbx --compare mixamo_ref.fbx --json`
— vertices are matched by position (Mixamo re-exports reorder
them), bones by name; the report gives mean/max per-vertex weight
L1 difference and the top-10 differing bones.
5. Also run `--evaluate` on both files and compare the metric table.

Interpretation: bone names differ between our templates and Mixamo's
(`mixamorig:*`) — rename with `qtmesh anim --rename` or map manually;
unmatched bone names inflate the L1 diff. The #819 target: UniRigML
within 10% of the Mixamo reference on the `--evaluate` metrics, GVB
within 20%.

The env-gated regression test (`SkinEvaluateTest.CompareAgainstReference`)
runs the comparison automatically when `QTMESH_SKIN_REF_FBX` (the
Mixamo file) and `QTMESH_SKIN_OURS_FBX` are set; it is skipped
otherwise, so CI stays hermetic.

### Recorded run

_Pending a manual Mixamo export (requires an Adobe account). Record
the `--compare` and `--evaluate` outputs here when first run._

## Dual-quaternion display (Slice D)

`Animation mode → Skinning → Display: Linear | Dual Quaternion`, or
MCP `set_skinning_display {mode: "dual-quaternion"}`. Kills the
candy-wrapper collapse on twists that no weight map can fix. Display
only: exporters consume the unchanged vertex weights — engines
re-skin with their own blend. Entities above the RTSS bone cap (96)
stay on the default path.
75 changes: 75 additions & 0 deletions qml/PropertiesPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -2308,6 +2308,81 @@ Rectangle {
: "Select a skinned mesh (with a skeleton) first."
}
}

// #819 Slice D: per-entity skinning display mode.
// Dual Quaternion (RTSS hardware skinning) preserves
// volume on twists — no candy-wrapper collapse. Display
// only: exported weights are unchanged.
Row {
id: skinDispRow
spacing: 6
visible: SkinWeightsController.hasSkinnedSelection

// Re-read the active mode when the selection changes.
property string activeMode: SkinWeightsController.skinningDisplayMode()
Connections {
target: SkinWeightsController
function onSelectionChanged() {
skinDispRow.activeMode =
SkinWeightsController.skinningDisplayMode()
}
Comment on lines +2321 to +2328

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refresh the active mode when runtime/MCP changes it.

This handler only reacts to selection changes, but MCPServer.cpp applies the mode directly without changing selection. The viewport can therefore be in Dual Quaternion mode while the Linear button remains highlighted until the user reselects the entity. Add an observable mode-changed notification or refresh this state after shared runtime changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qml/PropertiesPanel.qml` around lines 2321 - 2328, The active mode state in
skinDispRow is refreshed only by onSelectionChanged, so runtime changes from
MCPServer.cpp can leave the UI stale. Add an observable SkinWeightsController
mode-changed signal and connect it alongside onSelectionChanged, or otherwise
trigger the same skinningDisplayMode() refresh after shared runtime mode
updates; ensure both user and MCP changes update skinDispRow.activeMode.

}

Text {
anchors.verticalCenter: parent.verticalCenter
text: "Display:"
color: PropertiesPanelController.textColor
font.pixelSize: 10
opacity: 0.8
}

Repeater {
model: [
{ label: "Linear", mode: "linear" },
{ label: "Dual Quaternion", mode: "dual-quaternion" }
]

Rectangle {
id: dispBtn
required property var modelData
readonly property bool active:
skinDispRow.activeMode === modelData.mode
width: dispLabel.implicitWidth + 14
height: 22
radius: 3
color: active ? PropertiesPanelController.highlightColor
: (dispMa.containsMouse
? PropertiesPanelController.headerColor
: PropertiesPanelController.inputColor)
border.color: PropertiesPanelController.borderColor
border.width: 1

Text {
id: dispLabel
anchors.centerIn: parent
text: dispBtn.modelData.label
color: PropertiesPanelController.textColor
font.pixelSize: 10
}
MouseArea {
id: dispMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (SkinWeightsController.setSkinningDisplayMode(
dispBtn.modelData.mode))
skinDispRow.activeMode = dispBtn.modelData.mode
}
ToolTip.visible: containsMouse
ToolTip.delay: 500
ToolTip.text: dispBtn.modelData.mode === "dual-quaternion"
? "Render with dual-quaternion hardware skinning — preserves volume on twists (no candy-wrapper). Display only: exported weights are unchanged; engines re-skin with their own blend."
: "Render with the default linear-blend skinning path."
}
Comment on lines +2345 to +2382

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the display controls keyboard- and screen-reader-accessible.

These new controls are raw Rectangle/MouseArea elements with no activeFocusOnTab, Accessible role/name/checked state, or keyboard activation. Users who cannot use a mouse cannot change the skinning mode. Use accessible radio/button controls or add the same focus and key handling used by the existing segmented controls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qml/PropertiesPanel.qml` around lines 2345 - 2382, Make the skinning display
options in the dispBtn delegate keyboard- and screen-reader-accessible by
replacing the raw Rectangle/MouseArea interaction with the project’s existing
accessible segmented-control pattern, or add equivalent activeFocusOnTab,
Accessible role/name/checked state, visible focus styling, and keyboard
activation for the Space/Enter keys. Preserve the existing
setSkinningDisplayMode and activeMode behavior, and ensure the controls expose
mutually exclusive radio-style selection.

}
}
}
}
}

Expand Down
74 changes: 72 additions & 2 deletions src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
#include "UvProject.h"
#include "QuadRetopo.h"
#include "SkinWeights.h"
#include "SkinEvaluate.h"
#include "AutoRig.h"
#include "ImageTo3D/MeshGenPredictor.h"
#include "ImageTo3D/TripoSGPredictor.h"
Expand Down Expand Up @@ -842,6 +843,13 @@
" on volume-less meshes). Weights are Laplacian-smoothed + pruned\n"
" (--smooth-iterations, 0 = off). Mesh must have a skeleton attached.\n"
" --merge keeps existing weights instead of replacing them.\n"
" skin <file> --evaluate [--voxel-res N] [--json]\n"
" Skin-quality metrics on the EXISTING weights (#819): influence\n"
" histogram, Laplacian smoothness energy, geodesic bleed fraction.\n"
" skin <file> --compare <reference> [--json]\n"
" Per-vertex weight diff vs a reference-skinned copy of the same asset\n"
" (e.g. Mixamo) — vertices matched by position, bones by name. See\n"
" docs/SKINNING_QUALITY.md for the comparison protocol.\n"
" morph <file> --list [--json] List morph targets / blend shapes on a mesh. (Set/add/delete\n"
" land in follow-up slices once authoring is in place.)\n"
" nodeanim <file> --list [--json] List node-animation clips on a scene (props, doors, machinery,\n"
Expand Down Expand Up @@ -8843,6 +8851,8 @@
QString algoName = QStringLiteral("geodesic-voxel");
int voxelRes = 64;
int smoothIterations = 3;
bool evaluateMode = false; // #819 Slice E: metrics, no write
QString comparePath; // #819 Slice E: reference-skin diff

for (int i = 1; i < argc; ++i) {
const QString arg = QString::fromLocal8Bit(argv[i]);
Expand Down Expand Up @@ -8908,6 +8918,10 @@
}
maxDistance = v; continue;
}
if (arg == "--evaluate") { evaluateMode = true; continue; }
if (arg == "--compare" && i + 1 < argc) {
comparePath = QString::fromLocal8Bit(argv[++i]); continue;
}
if (!arg.startsWith("-") && inputPath.isEmpty()) {
inputPath = arg; continue;
}
Expand All @@ -8919,11 +8933,14 @@
"[--algo geodesic-voxel|inverse-distance|unirig] "
"[--max-influences N] [--falloff F] [--max-distance D] "
"[--voxel-res N] [--smooth-iterations N] "
"[--skip-unweighted] [--merge] -o <out> [--json]"
"[--skip-unweighted] [--merge] -o <out> [--json]\n"
" qtmesh skin <file> --evaluate [--voxel-res N] [--json]\n"
" qtmesh skin <file> --compare <reference> [--json]"
<< Qt::endl;
return 2;
}
if (outputPath.isEmpty()) {
const bool analysisMode = evaluateMode || !comparePath.isEmpty();
if (outputPath.isEmpty() && !analysisMode) {
err() << "Error: -o <output> required." << Qt::endl;
return 2;
}
Expand All @@ -8932,6 +8949,10 @@
if (!fi.exists()) {
err() << "Error: file not found: " << inputPath << Qt::endl; return 1;
}
if (!comparePath.isEmpty() && !QFileInfo::exists(comparePath)) {
err() << "Error: reference file not found: " << comparePath << Qt::endl;
return 1;
}
if (!initOgreHeadless()) return 1;

SentryReporter::addBreadcrumb(
Expand Down Expand Up @@ -8963,6 +8984,55 @@
}
Ogre::Entity* entity = meshEntities.first();

// ── #819 Slice E: metrics / reference-comparison modes ─────────
// Both analyse the EXISTING weights; nothing is written.
if (analysisMode) {
QJsonObject evalReport;
QString evalError;
if (!comparePath.isEmpty()) {
SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.skin.compare"),
QString("skin --compare .%1").arg(fi.suffix()));
// Import the reference into the same scene and diff the
// sets to find its entity.
SentryReporter::addBreadcrumb(QStringLiteral("file.import"),
QString("Importing %1").arg(QFileInfo(comparePath).absoluteFilePath()));
MeshImporterExporter::importer(
{QFileInfo(comparePath).absoluteFilePath()});
Ogre::Entity* refEntity = nullptr;
for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) {
if (e && e->getMovableType() == "Entity"

Check failure on line 9003 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not nest more than 3 if|for|do|while|switch statements.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9KaLI0gg1BN7bXzG7I&open=AZ9KaLI0gg1BN7bXzG7I&pullRequest=830
&& !meshEntities.contains(e)) {
refEntity = e;
break;
}
}
Comment on lines +9002 to +9008

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add multi-entity guard for the reference file.

The reference entity is found by picking the first new Ogre::Entity* not in meshEntities. If the reference file imports multiple entities, only the first is used and the rest are silently ignored, potentially producing incorrect comparison results. The input file already has a multi-entity check (lines 8978-8984), but the reference does not.

🛡️ Proposed multi-entity guard
             Ogre::Entity* refEntity = nullptr;
+            int newCount = 0;
             for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) {
                 if (e && e->getMovableType() == "Entity"
                     && !meshEntities.contains(e)) {
-                    refEntity = e;
-                    break;
+                    if (!refEntity) refEntity = e;
+                    ++newCount;
                 }
             }
+            if (newCount > 1) {
+                err() << "Error: reference " << comparePath
+                      << " contains multiple mesh entities. `qtmesh skin --compare` "
+                         "currently supports one entity per file."
+                      << Qt::endl;
+                return 1;
+            }
             if (!refEntity) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) {
if (e && e->getMovableType() == "Entity"
&& !meshEntities.contains(e)) {
refEntity = e;
break;
}
}
Ogre::Entity* refEntity = nullptr;
int newCount = 0;
for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) {
if (e && e->getMovableType() == "Entity"
&& !meshEntities.contains(e)) {
if (!refEntity) refEntity = e;
+newCount;
}
}
if (newCount > 1) {
err() << "Error: reference " << comparePath
<< " contains multiple mesh entities. `qtmesh skin --compare` "
"currently supports one entity per file."
<< Qt::endl;
return 1;
}
if (!refEntity) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/CLIPipeline.cpp` around lines 9002 - 9008, Add a multi-entity validation
after the reference import and entity-discovery loop associated with refEntity:
count all newly imported Ogre::Entity objects not present in meshEntities,
reject the reference file when more than one is found, and report the same
error/cleanup outcome used by the existing input-file multi-entity check. Ensure
refEntity is assigned only for the single valid entity and prevent comparison
from continuing with silently ignored entities.

if (!refEntity) {
err() << "Error: failed to load reference " << comparePath
<< Qt::endl;
return 1;
}
evalReport = SkinEvaluate::compare(entity, refEntity, &evalError);
} else {
SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.skin.evaluate"),
QString("skin --evaluate .%1 voxelRes=%2")
.arg(fi.suffix()).arg(voxelRes));
evalReport = SkinEvaluate::evaluate(entity, voxelRes, &evalError);
}
if (evalReport.isEmpty()) {
err() << "Error: " << (evalError.isEmpty()
? QStringLiteral("evaluation failed") : evalError)
<< Qt::endl;
return 1;
}
if (jsonOutput) {
cliWrite(QString::fromUtf8(
QJsonDocument(evalReport).toJson(QJsonDocument::Indented)) + "\n");
} else {
cliWrite(SkinEvaluate::reportToText(evalReport));
}
return 0;
}

SkinWeightsOptions opts;
opts.maxInfluencesPerVertex = maxInfluences;
opts.falloff = falloff;
Expand Down
6 changes: 6 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ QuadRetopoController.cpp
SkinWeights.cpp
SkinWeightsPost.cpp
GeodesicVoxelBind.cpp
SkinningDisplay.cpp
SkinMetrics.cpp
SkinEvaluate.cpp
SkinWeightsController.cpp
AutoRig.cpp
AutoRigController.cpp
Expand Down Expand Up @@ -299,6 +302,9 @@ QuadRetopoController.h
SkinWeights.h
SkinWeightsPost.h
GeodesicVoxelBind.h
SkinningDisplay.h
SkinMetrics.h
SkinEvaluate.h
SkinWeightsController.h
AutoRig.h
AutoRigController.h
Expand Down
Loading
Loading