diff --git a/CLAUDE.md b/CLAUDE.md index a9d93b0f..bc20c330 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ qtmesh uv model.fbx --unwrap -o unwrapped.glb # xatlas auto-UV unwrap (#400). N qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb # write into UV1 (lightmap workflow) ``` -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`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`) 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`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`) 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. @@ -237,6 +237,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **MeshOptimizerLod** (`src/MeshOptimizerLod.h/cpp`, issue #398): Thin facade over `zeux/meshoptimizer` for LOD generation. Free functions, no singleton. `generateLods(mesh, reductions)` returns one `LodLevel` per requested reduction, each with one `Ogre::IndexData*` per submesh. Uses `meshopt_simplifyWithAttributes` when UV0 is present (preserves UV seams), falls back to `meshopt_simplify` otherwise. Every result is `meshopt_optimizeVertexCache`-reordered (Forsyth) so the LOD is cache-friendly out of the box. Caller takes ownership of the `IndexData*` (commit to `SubMesh::mLodFaceList` or call `destroyLevel`). - **MeshLodController** (`src/MeshLodController.h/cpp`): Now has `Algorithm` enum (`Ogre` | `Meshopt`) on the C++ overload `generateLods(int, const QVariantList&, Algorithm)`. QML-facing `generateLodsWithAlgo(int, QVariantList, QString)` accepts `"ogre"` / `"meshopt"` for the Inspector backend dropdown. Default is `Ogre` — meshoptimizer's attribute-weighted simplify preserves UV seams + skin weights but in practice produces a softer silhouette than Ogre's stock `MeshLodGenerator` on character meshes, so Ogre stays primary. CLI: `--algo ogre|meshopt` (default `ogre`). MCP `generate_lods` tool: `algo` param (default `ogre`). Sentry breadcrumb category `ai.assist.lod` records the chosen backend when meshopt is used. - **MeshDecimator** (`src/MeshDecimator.h/cpp`): Same `Algorithm` enum exposed on `decimateEntity(entity, reduction, algo)`. `MeshDecimatorController::applyReductionWithAlgo(double, QString)` is the QML-facing variant the Inspector's Decimate section dropdown calls. CLI `qtmesh decimate ... --algo ogre|meshopt`; MCP `decimate_mesh` `algo` param. Same default and breadcrumb category as the LOD path (`ai.assist.decimate` for meshopt). The post-decimation `promoteFirstLodToBase` also erases the `qtme.faces.` n-gon bindings, otherwise FBXExporter (and EditableMesh) rehydrate the original triangle list off the cached binding and emit the un-decimated mesh. +- **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. - **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/README.md b/README.md index dbf1d603..6933488e 100755 --- a/README.md +++ b/README.md @@ -179,6 +179,11 @@ qtmesh uv model.fbx --info --json # report UV channels qtmesh retopo model.fbx -o quads.glb # pair every viable triangle into quads qtmesh retopo model.fbx --target-faces 5000 -o lo.glb # stop early once near target face count qtmesh retopo model.fbx --max-angle 15 -o conservative.glb # tighter coplanarity gate + +# Compute skin weights (inverse-distance heuristic; mesh must have a skeleton) +qtmesh skin model.fbx -o skinned.glb # default 4 influences, falloff 4 +qtmesh skin model.fbx --max-influences 8 --falloff 6 -o skinned.glb +qtmesh skin model.fbx --skip-unweighted --merge -o filled.glb # fill missing weights only ``` --- diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 31fa0489..0e2e044f 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -301,6 +301,24 @@ Rectangle { Component.onCompleted: content = animationModeToolsComponent } + // ---- Skinning (Animation mode) ---- + // Issue #402: auto skin weights. Surfaced in Animation + // Mode because skinning governs how the mesh deforms + // under animation — it's a rigging step, not a mesh-topology + // edit. Gated on having a *skinned* selection (a skeleton), + // NOT on hasAnimations — a skeleton-bearing mesh with no + // clips yet still needs skinning, and that's exactly the + // case where you'd want to author weights before animating. + CollapsibleSection { + title: "Skinning" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && SkinWeightsController.hasSkinnedSelection + expanded: false + + Component.onCompleted: content = skinningToolsComponent + } + // ---- Texture Paint (Material mode) ---- // (Brush color/radius/strength/falloff live on the toolbar // paint-brush popup. The Inspector panel keeps only the @@ -1092,6 +1110,76 @@ Rectangle { } } + // ---- Skinning Tools Content (Animation mode) ---- + // Issue #402: auto skin weights. The "Compute Skin Weights…" + // button opens the dialog; it disables on static meshes + // (no skeleton). The operation is undoable (Ctrl+Z). + Component { + id: skinningToolsComponent + + Column { + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + opacity: 0.8 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: "Auto-generate per-vertex bone weights for the selected " + + "skinned mesh (inverse-distance smooth bind). Undoable." + } + + Rectangle { + id: skinBtn + width: Math.min(parent.width - 16, skinLabel.implicitWidth + 16) + height: 26 + radius: 3 + opacity: SkinWeightsController.hasSkinnedSelection ? 1.0 : 0.45 + color: skinMa.containsMouse && SkinWeightsController.hasSkinnedSelection + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + // Keyboard accessibility: tab focus + Enter/Space opens + // the dialog, with a focus-ring border so keyboard users + // can see where they are. + activeFocusOnTab: SkinWeightsController.hasSkinnedSelection + Accessible.role: Accessible.Button + Accessible.name: "Compute Skin Weights" + Keys.onSpacePressed: if (SkinWeightsController.hasSkinnedSelection) root.openSkinWeightsDialog() + Keys.onReturnPressed: if (SkinWeightsController.hasSkinnedSelection) root.openSkinWeightsDialog() + Keys.onEnterPressed: if (SkinWeightsController.hasSkinnedSelection) root.openSkinWeightsDialog() + border.color: skinBtn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: skinBtn.activeFocus ? 2 : 1 + + Text { + id: skinLabel + anchors.centerIn: parent + text: "Compute Skin Weights…" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: skinMa + anchors.fill: parent + hoverEnabled: true + enabled: SkinWeightsController.hasSkinnedSelection + cursorShape: SkinWeightsController.hasSkinnedSelection + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: root.openSkinWeightsDialog() + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: SkinWeightsController.hasSkinnedSelection + ? "Compute per-vertex bone weights via inverse-distance to bone segments. Mesh must have a skeleton." + : "Select a skinned mesh (with a skeleton) first." + } + } + } + } + // ---- Edit Mode Tools Content ---- Component { id: editModeToolsComponent @@ -3902,6 +3990,23 @@ Rectangle { } } + // Issue #402: inverse-distance skin weights dialog. Same lazy- + // load idiom as the sibling dialogs. + Loader { + id: skinWeightsLoader + active: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/SkinWeightsDialog.qml" + onLoaded: if (item && item.open) item.open() + } + function openSkinWeightsDialog() { + if (!skinWeightsLoader.active) { + skinWeightsLoader.active = true + } else if (skinWeightsLoader.item) { + skinWeightsLoader.item.open() + } + } + // ---- Material Presets Content ---- Component { id: materialPresetsComponent diff --git a/qml/SkinWeightsDialog.qml b/qml/SkinWeightsDialog.qml new file mode 100644 index 00000000..0f3e6831 --- /dev/null +++ b/qml/SkinWeightsDialog.qml @@ -0,0 +1,343 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import MaterialEditorQML 1.0 +import PropertiesPanel 1.0 + +// Issue #402: top-level Window for inverse-distance skin weights. +// Same Inspector-styled idiom as QuadRetopoDialog / UvUnwrapDialog. +// Operates on the currently selected entity — the mesh must have +// a skeleton attached, otherwise the button disables itself. +Window { + id: dialog + title: "Skin Weights" + width: 560 + height: 440 + minimumWidth: 480 + minimumHeight: 400 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + // Knobs — defaults match SkinWeightsOptions. + property int maxInfluences: 4 + property double falloff: 4.0 + property double maxInfluenceDistance: 0.5 + property bool skipUnweightedBones: false + property bool replaceExisting: true + + property string lastStatus: "" + property bool lastWasError: false + + function open() { + dialog.lastStatus = "" + dialog.lastWasError = false + dialog.show() + dialog.raise() + dialog.requestActivate() + keyCapture.forceActiveFocus() + } + + function runCompute() { + if (SkinWeightsController.busy) return + if (!SkinWeightsController.hasSkinnedSelection) return + const r = SkinWeightsController.computeWeightsForSelected( + dialog.maxInfluences, + dialog.falloff, + dialog.maxInfluenceDistance, + dialog.skipUnweightedBones, + dialog.replaceExisting) + if (r && r.applied) { + dialog.lastStatus = + "Done: " + r.totalBones + " bones, " + + r.totalVerticesProcessed + " verts, " + + r.totalAssignmentsBefore + " → " + + r.totalAssignmentsAfter + " assignments" + dialog.lastWasError = false + } else { + dialog.lastStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + dialog.lastWasError = true + } + } + + Item { + id: keyCapture + anchors.fill: parent + focus: true + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Escape) { + dialog.close() + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + dialog.runCompute() + event.accepted = true + } + } + } + + // ── Inline Inspector primitives ───────────────────────────────── + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + // Keyboard accessibility: tabbable + Enter/Space activates. + activeFocusOnTab: buttonEnabled + Accessible.role: Accessible.Button + Accessible.name: btn.label + Keys.onSpacePressed: if (buttonEnabled) btn.clicked() + Keys.onReturnPressed: if (buttonEnabled) btn.clicked() + Keys.onEnterPressed: if (buttonEnabled) btn.clicked() + height: 26 + radius: 3 + color: btnMa.containsMouse && buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + // A subtle focus ring so keyboard users can see where they are. + border.color: btn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: btn.activeFocus ? 2 : 1 + opacity: buttonEnabled ? 1.0 : 0.45 + Text { + anchors.centerIn: parent + text: btn.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: btnMa + anchors.fill: parent + hoverEnabled: true + enabled: btn.buttonEnabled + cursorShape: btn.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: btn.clicked() + } + } + + component InspectorLabel: Text { + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + + component InspectorNumberField: Rectangle { + id: nf + property double value: 0 + property double minValue: 0 + property double maxValue: 1e9 + property bool isInt: false + signal newValue(double v) + height: 24 + color: PropertiesPanelController.inputColor + border.color: ni.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + TextInput { + id: ni + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + text: nf.isInt ? Math.round(nf.value).toString() : nf.value.toFixed(2) + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + onEditingFinished: { + const n = nf.isInt ? parseInt(text, 10) : parseFloat(text) + if (!isNaN(n) && n >= nf.minValue && n <= nf.maxValue) + nf.newValue(n) + else + text = nf.isInt ? Math.round(nf.value).toString() : nf.value.toFixed(2) + } + } + } + + component InspectorCheckbox: Rectangle { + id: cb + property string label: "" + property bool checked: false + signal toggled() + // Keyboard accessibility: tabbable + Enter/Space toggles. + activeFocusOnTab: true + Accessible.role: Accessible.CheckBox + Accessible.name: cb.label + Accessible.checked: cb.checked + Keys.onSpacePressed: cb.toggled() + Keys.onReturnPressed: cb.toggled() + Keys.onEnterPressed: cb.toggled() + height: 16 + width: parent ? parent.width : 200 + color: "transparent" + Row { + spacing: 6 + Rectangle { + width: 14; height: 14 + radius: 2 + color: PropertiesPanelController.inputColor + // Focus ring on the box itself when the checkbox has focus. + border.color: cb.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: cb.activeFocus ? 2 : 1 + Text { + anchors.centerIn: parent + text: cb.checked ? "✓" : "" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + } + InspectorLabel { text: cb.label } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: cb.toggled() + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.85 + text: "Compute per-vertex skin weights from the bind-pose distance " + + "to each bone segment. Inverse-distance heuristic (closest-" + + "point-on-bone smooth bind) — the same default Maya / 3dsMax " + + "use. Falloff controls the sharpness of the bind. The mesh " + + "must have a skeleton attached. Existing bone assignments " + + "are replaced (or merged — see option)." + } + + // Max influences + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Max influences:"; Layout.preferredWidth: 130 } + InspectorNumberField { + Layout.preferredWidth: 80 + value: dialog.maxInfluences + minValue: 1 + maxValue: 8 + isInt: true + onNewValue: dialog.maxInfluences = Math.round(v) + } + InspectorLabel { + text: "bones per vertex (hardware skinning convention: 4)" + opacity: 0.7 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + // Falloff + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Falloff:"; Layout.preferredWidth: 130 } + InspectorNumberField { + Layout.preferredWidth: 80 + value: dialog.falloff + minValue: 0.5 + maxValue: 16.0 + onNewValue: dialog.falloff = v + } + InspectorLabel { + text: "higher = sharper bind (more rigid); lower = smoother blends" + opacity: 0.7 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + // Max influence distance + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Max distance:"; Layout.preferredWidth: 130 } + InspectorNumberField { + Layout.preferredWidth: 80 + value: dialog.maxInfluenceDistance + minValue: 0 + maxValue: 10 + onNewValue: dialog.maxInfluenceDistance = v + } + InspectorLabel { + text: "fraction of mesh diagonal (0 = no cap; default 0.5)" + opacity: 0.7 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + // Skip unweighted bones + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: ""; Layout.preferredWidth: 130 } + InspectorCheckbox { + Layout.fillWidth: true + label: "Skip bones with no existing weights (Mixamo helper bones)" + checked: dialog.skipUnweightedBones + onToggled: dialog.skipUnweightedBones = !dialog.skipUnweightedBones + } + } + + // Replace existing + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: ""; Layout.preferredWidth: 130 } + InspectorCheckbox { + Layout.fillWidth: true + label: "Replace existing weights (unchecked = merge / fill-in mode)" + checked: dialog.replaceExisting + onToggled: dialog.replaceExisting = !dialog.replaceExisting + } + } + + Item { Layout.fillHeight: true } + + // Status line + InspectorLabel { + Layout.fillWidth: true + visible: dialog.lastStatus.length > 0 + text: dialog.lastStatus + wrapMode: Text.WordWrap + color: dialog.lastWasError ? "#cc4444" : "#3a8c3a" + } + + // Buttons + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + InspectorButton { + label: "Close" + Layout.preferredWidth: 90 + onClicked: dialog.close() + } + InspectorButton { + label: SkinWeightsController.busy ? "Computing…" : "Compute Weights" + Layout.preferredWidth: 160 + buttonEnabled: !SkinWeightsController.busy + && SkinWeightsController.hasSkinnedSelection + onClicked: dialog.runCompute() + } + } + } + + Connections { + target: SkinWeightsController + function onError(msg) { + dialog.lastStatus = "Failed: " + msg + dialog.lastWasError = true + } + } +} diff --git a/qml/qmldir b/qml/qmldir index 9566c08e..6da2e12d 100644 --- a/qml/qmldir +++ b/qml/qmldir @@ -9,6 +9,7 @@ TextureAtlasDialog 1.0 TextureAtlasDialog.qml ApplyAtlasDialog 1.0 ApplyAtlasDialog.qml UvUnwrapDialog 1.0 UvUnwrapDialog.qml QuadRetopoDialog 1.0 QuadRetopoDialog.qml +SkinWeightsDialog 1.0 SkinWeightsDialog.qml MaterialListModal 1.0 MaterialListModal.qml ThemedButton 1.0 ThemedButton.qml ThemedCheckBox 1.0 ThemedCheckBox.qml diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index ceb24f71..e1337d75 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -21,6 +21,7 @@ #include "ExportOptimizer.h" #include "UvUnwrap.h" #include "QuadRetopo.h" +#include "SkinWeights.h" #include "MeshDecimator.h" #include "EditableMesh.h" #include "TexturePaintBuffer.h" @@ -722,6 +723,11 @@ void CLIPipeline::printUsage() " gates pass. Writes quads via the n-gon binding so the FBX / glTF\n" " exporter round-trips them. No new vertices are introduced — UVs\n" " and skin weights survive unchanged.\n" + " skin [--max-influences N] [--falloff F] [--max-distance D] [--skip-unweighted] [--merge] -o [--json]\n" + " Compute skin weights via inverse-distance heuristic (closest-point-on-\n" + " bone smooth bind). Mesh must have a skeleton attached. Bones with no\n" + " existing weights can be filtered with --skip-unweighted. --merge\n" + " keeps existing weights instead of replacing them.\n" " morph --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 --list [--json] List node-animation clips on a scene (props, doors, machinery,\n" @@ -1265,6 +1271,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "vat") rc = cmdVat(argc, 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 == "morph") rc = cmdMorph(argc, argv); else if (cmd == "nodeanim") rc = cmdNodeAnim(argc, argv); @@ -6866,6 +6873,136 @@ int CLIPipeline::cmdRetopo(int argc, char* argv[]) return 0; } +int CLIPipeline::cmdSkin(int argc, char* argv[]) +{ + // Parse: skin [--max-influences N] [--falloff F] + // [--max-distance D] [--skip-unweighted] [--merge] -o [--json] + QString inputPath, outputPath; + bool jsonOutput = false; + int maxInfluences = 4; + double falloff = 4.0; + double maxDistance = 0.5; + bool skipUnweighted = false; + bool replaceExisting = true; + + for (int i = 1; i < argc; ++i) { + const QString arg = QString::fromLocal8Bit(argv[i]); + if (arg == "skin" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--skip-unweighted") { skipUnweighted = true; continue; } + if (arg == "--merge") { replaceExisting = false; continue; } + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString::fromLocal8Bit(argv[++i]); continue; + } + if (arg == "--max-influences" && i + 1 < argc) { + bool ok = false; + const int v = QString::fromLocal8Bit(argv[++i]).toInt(&ok); + if (!ok || v < 1 || v > 8) { + err() << "Error: --max-influences must be in [1, 8]." << Qt::endl; + return 2; + } + maxInfluences = v; continue; + } + if (arg == "--falloff" && i + 1 < argc) { + bool ok = false; + const double v = QString::fromLocal8Bit(argv[++i]).toDouble(&ok); + if (!ok || v < 0.5 || v > 16.0) { + err() << "Error: --falloff must be in [0.5, 16]." << Qt::endl; + return 2; + } + falloff = v; continue; + } + if (arg == "--max-distance" && i + 1 < argc) { + bool ok = false; + const double v = QString::fromLocal8Bit(argv[++i]).toDouble(&ok); + if (!ok || v < 0.0 || v > 10.0) { + err() << "Error: --max-distance must be in [0, 10]." << Qt::endl; + return 2; + } + maxDistance = v; continue; + } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh skin [--max-influences N] [--falloff F] " + "[--max-distance D] [--skip-unweighted] [--merge] -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.skin_weights"), + QString("skin .%1 maxInf=%2 falloff=%3") + .arg(fi.suffix()).arg(maxInfluences).arg(falloff)); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + // Filter to real Ogre::Entity objects — Manager::getEntities() + // can include helper ManualObjects, which would make a + // single-entity file look multi-entity (and cast wrong). + 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 skin` " + "currently supports one entity per file." + << Qt::endl; + return 1; + } + Ogre::Entity* entity = meshEntities.first(); + + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = maxInfluences; + opts.falloff = falloff; + opts.maxInfluenceDistance = maxDistance; + opts.skipUnweightedBones = skipUnweighted; + opts.replaceExisting = replaceExisting; + + const auto report = SkinWeights::computeAndApply(entity, opts); + if (!report.applied) { + err() << "Error: skin weights failed — " << report.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) { + cliWrite(QString::fromUtf8( + QJsonDocument(SkinWeights::reportToJson(report)).toJson(QJsonDocument::Indented)) + "\n"); + } else { + cliWrite(SkinWeights::reportToText(report) + + 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 fb0bcefb..a2e14cfe 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -153,6 +153,10 @@ class CLIPipeline { /// round-trip through the FBX / glTF exporter. Issue #401. static int cmdRetopo(int argc, char* argv[]); + /// Compute skin weights for a mesh + skeleton via inverse- + /// distance heuristic. Issue #402. + static int cmdSkin(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/CMakeLists.txt b/src/CMakeLists.txt index ee737184..d1f1529f 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -73,6 +73,7 @@ commands/MorphCommands.cpp commands/NodeAnimCommands.cpp commands/PoseLibraryCommands.cpp commands/SkeletonResolver.cpp +commands/ComputeSkinWeightsCommand.cpp BoneDragRelease.cpp PropertiesPanelController.cpp SceneTreeModel.cpp @@ -86,6 +87,8 @@ UvUnwrap.cpp UvUnwrapController.cpp QuadRetopo.cpp QuadRetopoController.cpp +SkinWeights.cpp +SkinWeightsController.cpp TextureChannelPacker.cpp TextureAtlasPacker.cpp PaintBufferImageProvider.cpp @@ -203,6 +206,8 @@ UvUnwrap.h UvUnwrapController.h QuadRetopo.h QuadRetopoController.h +SkinWeights.h +SkinWeightsController.h TextureChannelPacker.h TextureAtlasPacker.h PaintBufferImageProvider.h diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 6dd3a3b2..a0ead5c5 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -28,6 +28,7 @@ #include "MeshDecimator.h" #include "UvUnwrap.h" #include "QuadRetopo.h" +#include "SkinWeights.h" #include #include #include @@ -550,8 +551,9 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("list_textures"), &MCPServer::toolListTextures}, {QStringLiteral("set_texture"), &MCPServer::toolSetTexture}, {QStringLiteral("export_mesh"), &MCPServer::toolExportMesh}, - {QStringLiteral("auto_uv_unwrap"), &MCPServer::toolAutoUvUnwrap}, - {QStringLiteral("retopologize"), &MCPServer::toolRetopologize}, + {QStringLiteral("auto_uv_unwrap"), &MCPServer::toolAutoUvUnwrap}, + {QStringLiteral("retopologize"), &MCPServer::toolRetopologize}, + {QStringLiteral("compute_skin_weights"), &MCPServer::toolComputeSkinWeights}, {QStringLiteral("get_scene_info"), &MCPServer::toolGetSceneInfo}, {QStringLiteral("take_screenshot"), &MCPServer::toolTakeScreenshot}, {QStringLiteral("create_primitive"), &MCPServer::toolCreatePrimitive}, @@ -1478,6 +1480,78 @@ QJsonObject MCPServer::toolRetopologize(const QJsonObject &args) return result; } +QJsonObject MCPServer::toolComputeSkinWeights(const QJsonObject &args) +{ + // Issue #402: inverse-distance skin weights for the currently + // selected entity. The entity's mesh must have a skeleton. + if (!hasSelectedEntities()) + return makeErrorResult("No mesh selected. Load a mesh first with load_mesh."); + + // Validate argument TYPES before reading. Qt's + // QJsonValue::toInt/toDouble/toBool silently return the default + // when the JSON type doesn't match (e.g. "falloff": "4" as a + // string, or "replace_existing": "false"), which would apply + // unintended settings instead of surfacing a usage error. + if (args.contains("max_influences") && !args["max_influences"].isDouble()) + return makeErrorResult("Error: 'max_influences' must be a number."); + if (args.contains("falloff") && !args["falloff"].isDouble()) + return makeErrorResult("Error: 'falloff' must be a number."); + if (args.contains("max_distance") && !args["max_distance"].isDouble()) + return makeErrorResult("Error: 'max_distance' must be a number."); + if (args.contains("skip_unweighted") && !args["skip_unweighted"].isBool()) + return makeErrorResult("Error: 'skip_unweighted' must be a boolean."); + if (args.contains("replace_existing") && !args["replace_existing"].isBool()) + return makeErrorResult("Error: 'replace_existing' must be a boolean."); + + SkinWeightsOptions opts; + if (args.contains("max_influences")) + opts.maxInfluencesPerVertex = args["max_influences"].toInt(4); + if (args.contains("falloff")) + opts.falloff = args["falloff"].toDouble(4.0); + if (args.contains("max_distance")) + opts.maxInfluenceDistance = args["max_distance"].toDouble(0.5); + if (args.contains("skip_unweighted")) + opts.skipUnweightedBones = args["skip_unweighted"].toBool(false); + if (args.contains("replace_existing")) + opts.replaceExisting = args["replace_existing"].toBool(true); + + if (opts.maxInfluencesPerVertex < 1 || opts.maxInfluencesPerVertex > 8) + return makeErrorResult("Error: 'max_influences' must be in [1, 8]."); + if (opts.falloff < 0.5 || opts.falloff > 16.0) + return makeErrorResult("Error: 'falloff' must be in [0.5, 16]."); + if (opts.maxInfluenceDistance < 0.0 || opts.maxInfluenceDistance > 10.0) + return makeErrorResult("Error: 'max_distance' must be in [0, 10]."); + + 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.skin_weights"), + QStringLiteral("compute_skin_weights entity=%1 maxInf=%2 falloff=%3") + .arg(QString::fromStdString(entity->getName())) + .arg(opts.maxInfluencesPerVertex).arg(opts.falloff)); + + SkinWeightsReport report; + try { + report = SkinWeights::computeAndApply(entity, opts); + } catch (const Ogre::Exception& e) { + return makeErrorResult(QStringLiteral("Ogre error: %1") + .arg(QString::fromStdString(e.getFullDescription()))); + } + + if (!report.applied) { + return makeErrorResult(QStringLiteral("Skin weights failed: %1").arg(report.error)); + } + + QJsonObject result = makeSuccessResult(SkinWeights::reportToText(report)); + result["skin"] = SkinWeights::reportToJson(report); + return result; +} + QJsonObject MCPServer::toolGetSceneInfo(const QJsonObject &args) { Q_UNUSED(args); @@ -5542,6 +5616,39 @@ QJsonArray MCPServer::buildToolsList() ); } + // compute_skin_weights + { + QJsonObject props; + props["max_influences"] = QJsonObject{{"type", "integer"}, + {"description", + "Max bones each vertex is influenced by (hardware skinning convention 4). " + "Range [1, 8]. Default 4."}}; + props["falloff"] = QJsonObject{{"type", "number"}, + {"description", + "Inverse-distance exponent. Higher = sharper bind (more like rigid). " + "Range [0.5, 16]. Default 4.0."}}; + props["max_distance"] = QJsonObject{{"type", "number"}, + {"description", + "Bones farther than this fraction of the mesh diagonal are excluded. " + "Range [0, 10] (0 disables). Default 0.5."}}; + props["skip_unweighted"] = QJsonObject{{"type", "boolean"}, + {"description", + "When true, bones with no existing vertex assignments are filtered out " + "(useful for Mixamo helper bones). Default false."}}; + props["replace_existing"] = QJsonObject{{"type", "boolean"}, + {"description", + "When true (default), overwrite existing bone assignments. When false, " + "merge — keep existing weights and add new ones for unweighted vertices."}}; + appendTool( + "compute_skin_weights", + "Compute and apply skin weights for the currently selected mesh against " + "its attached skeleton. Uses an inverse-distance heuristic (closest-point-" + "on-bone smooth bind) — the same approach Maya / 3dsMax use as their " + "default. The mesh must have a skeleton attached. Issue #402.", + props + ); + } + // retopologize { QJsonObject props; diff --git a/src/MCPServer.h b/src/MCPServer.h index dd6d206f..6f4a7e43 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -150,6 +150,9 @@ private slots: /// convex quads when coplanarity + shape + aspect ratio gates /// pass. Output is committed via the n-gon binding. QJsonObject toolRetopologize(const QJsonObject &args); + /// Issue #402: compute skin weights via inverse-distance + /// heuristic. Mesh must have a skeleton attached. + QJsonObject toolComputeSkinWeights(const QJsonObject &args); QJsonObject toolGetSceneInfo(const QJsonObject &args); QJsonObject toolTakeScreenshot(const QJsonObject &args); QJsonObject toolCreatePrimitive(const QJsonObject &args); diff --git a/src/SkinWeights.cpp b/src/SkinWeights.cpp new file mode 100644 index 00000000..6c205e51 --- /dev/null +++ b/src/SkinWeights.cpp @@ -0,0 +1,484 @@ +#include "SkinWeights.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +// ─── Geometry helpers ─────────────────────────────────────────────────────── + +struct Vec3 { + double x = 0, y = 0, z = 0; + Vec3 operator-(const Vec3& o) const { return { x - o.x, y - o.y, z - o.z }; } + double dot(const Vec3& o) const { return x * o.x + y * o.y + z * o.z; } + double lengthSq() const { return x * x + y * y + z * z; } +}; + +// Distance from point P to segment AB. If A==B, falls through to +// point-point distance. Returns squared distance to avoid an +// unnecessary sqrt — comparisons and inverse-weighting work fine +// against the squared value (we apply `pow(d, -falloff/2)` at the +// inverse step). +double distSqPointSegment(const Vec3& p, const Vec3& a, const Vec3& b) +{ + const Vec3 ab = b - a; + const double abLenSq = ab.lengthSq(); + if (abLenSq < 1e-18) { + // A == B (leaf or single-point bone) → point distance. + return (p - a).lengthSq(); + } + const Vec3 ap = p - a; + const double t = std::clamp(ap.dot(ab) / abLenSq, 0.0, 1.0); + const Vec3 closest = { a.x + t * ab.x, a.y + t * ab.y, a.z + t * ab.z }; + return (p - closest).lengthSq(); +} + +// ─── Top-K-with-min-heap helper ───────────────────────────────────────────── + +// Push (boneIdx, weight) into the per-vertex top-K array, +// maintaining a sorted-descending order so the smallest weight is +// always at index `K-1`. This is O(K) per push but K is tiny +// (typically 4) — overall O(V·B·K) which is fine for asset-time +// processing. +void pushTopK(SkinWeights::VertexWeights& vw, int maxK, + int boneIdx, double weight) +{ + if (vw.count < maxK) { + // Slot available — insert in sorted position. + int i = vw.count; + while (i > 0 && vw.weights[i - 1] < weight) { + vw.weights[i] = vw.weights[i - 1]; + vw.boneIndices[i] = vw.boneIndices[i - 1]; + --i; + } + vw.weights[i] = weight; + vw.boneIndices[i] = boneIdx; + ++vw.count; + return; + } + // Array full — replace smallest if our new weight beats it. + if (weight <= vw.weights[maxK - 1]) return; + int i = maxK - 1; + while (i > 0 && vw.weights[i - 1] < weight) { + vw.weights[i] = vw.weights[i - 1]; + vw.boneIndices[i] = vw.boneIndices[i - 1]; + --i; + } + vw.weights[i] = weight; + vw.boneIndices[i] = boneIdx; +} + +} // namespace + +// ─── Public API ──────────────────────────────────────────────────────────── + +bool SkinWeights::computeWeights(const float* vertexPositions, + int vertexCount, + const std::vector& bones, + const SkinWeightsOptions& opts, + std::vector& outWeights) +{ + outWeights.clear(); + if (!vertexPositions || vertexCount < 1 || bones.empty()) return false; + + const int maxK = std::clamp(opts.maxInfluencesPerVertex, 1, 8); + const double falloffOver2 = std::max(0.5, opts.falloff) * 0.5; + + // Compute the bounding box for the distance-cap calculation. + double minX = std::numeric_limits::max(), minY = minX, minZ = minX; + double maxX = -minX, maxY = -minX, maxZ = -minX; + for (int i = 0; i < vertexCount; ++i) { + const float* p = vertexPositions + 3 * i; + minX = std::min(minX, p[0]); maxX = std::max(maxX, p[0]); + minY = std::min(minY, p[1]); maxY = std::max(maxY, p[1]); + minZ = std::min(minZ, p[2]); maxZ = std::max(maxZ, p[2]); + } + const double diag = std::sqrt((maxX - minX) * (maxX - minX) + + (maxY - minY) * (maxY - minY) + + (maxZ - minZ) * (maxZ - minZ)); + const double maxDistSq = (opts.maxInfluenceDistance > 0) + ? std::pow(opts.maxInfluenceDistance * diag, 2.0) + : std::numeric_limits::infinity(); + + outWeights.resize(vertexCount); + + for (int v = 0; v < vertexCount; ++v) { + const float* p = vertexPositions + 3 * v; + const Vec3 pos { p[0], p[1], p[2] }; + VertexWeights& vw = outWeights[v]; + + for (size_t b = 0; b < bones.size(); ++b) { + const auto& seg = bones[b]; + const Vec3 head { seg.headX, seg.headY, seg.headZ }; + const Vec3 tail { seg.tailX, seg.tailY, seg.tailZ }; + const double distSq = distSqPointSegment(pos, head, tail); + if (distSq > maxDistSq) continue; + // Inverse-distance weight: w_b = 1 / (distSq^(falloff/2)) + // == 1 / d^falloff. Add a tiny epsilon to dist to avoid + // a divide-by-zero on vertices that sit exactly on a + // bone segment. + const double w = 1.0 / std::pow(distSq + 1e-12, falloffOver2); + pushTopK(vw, maxK, static_cast(b), w); + } + + // Normalize so the kept weights sum to 1. + double sum = 0.0; + for (int i = 0; i < vw.count; ++i) sum += vw.weights[i]; + if (sum > 0.0) { + for (int i = 0; i < vw.count; ++i) vw.weights[i] /= sum; + } else if (!bones.empty()) { + // Vertex outside every bone's influence radius — pin to + // bone 0 with weight 1.0 so it still moves with the rig. + vw.boneIndices[0] = 0; + vw.weights[0] = 1.0; + vw.count = 1; + } + } + + return true; +} + +namespace { + +// Walk every submesh of the entity, gather flat vertex positions +// transformed into the entity-local space the skeleton is +// expressed in, build the bone segment list from the skeleton's +// bind pose, run `computeWeights`, then commit the result via +// `SubMesh::addBoneAssignment`/`_compileBoneAssignments`. +SkinWeightsReport applyToEntity(Ogre::Entity* entity, + const SkinWeightsOptions& opts) +{ + SkinWeightsReport report; + if (!entity) { report.error = QStringLiteral("null entity"); return report; } + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) { report.error = QStringLiteral("entity has no mesh"); return report; } + Ogre::Skeleton* skel = mesh->getSkeleton().get(); + if (!skel) { + report.error = QStringLiteral("mesh has no skeleton attached"); + return report; + } + + report.meshName = QString::fromStdString(mesh->getName()); + report.skeletonName = QString::fromStdString(skel->getName()); + + // Build the bone-segment list in bind pose. The bind pose is + // Ogre's "initial state" — `Bone::setBindingPose` was called + // by the mesh loader and `Bone::reset()` returns to it. We + // call reset() temporarily so `_getDerivedPosition` returns + // the bind-pose world position rather than whatever animation + // frame is currently active. + skel->reset(true); + + const unsigned short numBones = skel->getNumBones(); + report.totalBones = numBones; + + // Optionally collect the set of already-weighted bones to + // filter helper bones (Mixamo's `mixamorig:HeadTop_End` etc. + // ship with zero weights). Build the set by scanning every + // submesh's existing bone assignments. + std::vector boneInUse(numBones, 0); + if (opts.skipUnweightedBones) { + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + const auto& ba = mesh->getSubMesh(si)->getBoneAssignments(); + for (const auto& kv : ba) + if (kv.second.boneIndex < numBones) + boneInUse[kv.second.boneIndex] = 1; + } + // Also consider mesh-level (shared) bone assignments. + for (const auto& kv : mesh->getBoneAssignments()) + if (kv.second.boneIndex < numBones) + boneInUse[kv.second.boneIndex] = 1; + } + + std::vector bones; + bones.reserve(numBones); + // Map from `bones[]` index → Ogre bone handle. When skipping + // unweighted bones the lists are sparse, so we need to + // translate back at commit time. + std::vector boneIdxToHandle; + boneIdxToHandle.reserve(numBones); + for (unsigned short bi = 0; bi < numBones; ++bi) { + if (opts.skipUnweightedBones && !boneInUse[bi]) continue; + Ogre::Bone* bone = skel->getBone(bi); + if (!bone) continue; + const Ogre::Vector3 head = bone->_getDerivedPosition(); + // Use the average child position as the "tail" — gives the + // bone a real segment for distance computation. Leaf bones + // fall back to head==tail (point distance). + Ogre::Vector3 tail = head; + const unsigned short numChildren = bone->numChildren(); + if (numChildren > 0) { + Ogre::Vector3 sum(0, 0, 0); + int kept = 0; + for (unsigned short c = 0; c < numChildren; ++c) { + auto* child = dynamic_cast(bone->getChild(c)); + if (!child) continue; + sum += child->_getDerivedPosition(); + ++kept; + } + if (kept > 0) tail = sum / static_cast(kept); + } + SkinWeights::BoneSegment seg; + seg.headX = head.x; seg.headY = head.y; seg.headZ = head.z; + seg.tailX = tail.x; seg.tailY = tail.y; seg.tailZ = tail.z; + bones.push_back(seg); + boneIdxToHandle.push_back(bi); + } + + if (bones.empty()) { + report.error = QStringLiteral("skeleton has no usable bones"); + return report; + } + + const size_t maxK = static_cast( + std::clamp(opts.maxInfluencesPerVertex, 1, 8)); + + // Helper: read tight xyz floats out of a VertexData's POSITION + // element. Returns false if the buffer is unusable. + auto extractPositions = [](Ogre::VertexData* vd, + std::vector& out) -> bool { + 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; + out.resize(static_cast(vd->vertexCount) * 3); + const size_t stride = vbuf->getVertexSize(); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t i = 0; i < vd->vertexCount; ++i) { + float* p = nullptr; + posElem->baseVertexPointerToElement(base + i * stride, &p); + out[3 * i + 0] = p[0]; + out[3 * i + 1] = p[1]; + out[3 * i + 2] = p[2]; + } + vbuf->unlock(); + return true; + }; + + // Helper: compute + commit weights against one assignment owner + // (either an Ogre::SubMesh* or the Ogre::Mesh* for shared + // vertices). `getList` / `clear` / `add` / `compile` are the + // four operations that differ between the two owners; everything + // else is shared. `mesh-local space` matches the skeleton's + // bind-pose world space for the same entity, so no transform is + // applied to the positions here. + auto computeAndCommit = + [&](Ogre::VertexData* vd, + const Ogre::Mesh::VertexBoneAssignmentList& existing, + const std::function& clearFn, + const std::function& addFn, + const std::function& compileFn, + SkinWeightsSubmeshReport& subReport) -> bool + { + std::vector positions; + if (!extractPositions(vd, positions)) return false; + + std::vector weights; + SkinWeights::computeWeights(positions.data(), + static_cast(vd->vertexCount), + bones, opts, weights); + + // Merge mode (`replaceExisting=false`): keep existing + // weights and only fill vertices that have NONE. Build the + // set of already-weighted vertex indices from the existing + // assignment list BEFORE we touch anything. In replace mode + // we clear the list outright. + std::vector alreadyWeighted; + if (opts.replaceExisting) { + clearFn(); + } else { + alreadyWeighted.assign(weights.size(), 0); + for (const auto& kv : existing) { + if (kv.second.vertexIndex < alreadyWeighted.size()) + alreadyWeighted[kv.second.vertexIndex] = 1; + } + } + + for (size_t v = 0; v < weights.size(); ++v) { + // In merge mode, skip vertices that already had weights — + // don't append a second normalized set on top of theirs. + if (!opts.replaceExisting && v < alreadyWeighted.size() + && alreadyWeighted[v]) + continue; + const auto& vw = weights[v]; + if (static_cast(vw.count) == maxK) + ++subReport.verticesWithMaxInfluences; + for (int k = 0; k < vw.count; ++k) { + Ogre::VertexBoneAssignment vba; + vba.vertexIndex = static_cast(v); + vba.boneIndex = boneIdxToHandle[vw.boneIndices[k]]; + vba.weight = static_cast(vw.weights[k]); + addFn(vba); + } + } + compileFn(); + subReport.verticesProcessed = static_cast(weights.size()); + return true; + }; + + // Process the shared vertex data once (if any submesh uses it). + // Ogre stores shared-vertex bone assignments on the Mesh, not + // the SubMesh — the FBX/glTF exporters read + // `Mesh::getBoneAssignments()` for shared geometry, so routing + // them to the submesh list would leave the export with stale / + // missing weights (Codex review on PR #699). + bool anySharedProcessed = false; + if (mesh->sharedVertexData) { + bool anySubUsesShared = false; + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + if (mesh->getSubMesh(si) && mesh->getSubMesh(si)->useSharedVertices) { + anySubUsesShared = true; + break; + } + } + if (anySubUsesShared) { + SkinWeightsSubmeshReport subReport; + subReport.submeshIndex = -1; // -1 == mesh-level shared data + subReport.boneAssignmentsBefore = + static_cast(mesh->getBoneAssignments().size()); + const auto existingShared = mesh->getBoneAssignments(); + if (computeAndCommit( + mesh->sharedVertexData, + existingShared, + [&]() { mesh->clearBoneAssignments(); }, + [&](const Ogre::VertexBoneAssignment& vba) { + mesh->addBoneAssignment(vba); + }, + [&]() { mesh->_compileBoneAssignments(); }, + subReport)) { + subReport.boneAssignmentsAfter = + static_cast(mesh->getBoneAssignments().size()); + report.submeshes.push_back(subReport); + report.totalVerticesProcessed += subReport.verticesProcessed; + report.totalAssignmentsBefore += subReport.boneAssignmentsBefore; + report.totalAssignmentsAfter += subReport.boneAssignmentsAfter; + anySharedProcessed = true; + } + } + } + + // Process every submesh that owns its own (non-shared) vertex + // data. + const unsigned short numSubs = mesh->getNumSubMeshes(); + for (unsigned short si = 0; si < numSubs; ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (!sub) continue; + if (sub->useSharedVertices) continue; // handled above + if (!sub->vertexData) continue; + + SkinWeightsSubmeshReport subReport; + subReport.submeshIndex = si; + subReport.boneAssignmentsBefore = + static_cast(sub->getBoneAssignments().size()); + const auto existingSub = sub->getBoneAssignments(); + if (computeAndCommit( + sub->vertexData, + existingSub, + [&]() { sub->clearBoneAssignments(); }, + [&](const Ogre::VertexBoneAssignment& vba) { + sub->addBoneAssignment(vba); + }, + [&]() { sub->_compileBoneAssignments(); }, + subReport)) { + subReport.boneAssignmentsAfter = + static_cast(sub->getBoneAssignments().size()); + report.submeshes.push_back(subReport); + report.totalVerticesProcessed += subReport.verticesProcessed; + report.totalAssignmentsBefore += subReport.boneAssignmentsBefore; + report.totalAssignmentsAfter += subReport.boneAssignmentsAfter; + } + } + + Q_UNUSED(anySharedProcessed); + report.applied = true; + return report; +} + +} // namespace + +SkinWeightsReport SkinWeights::computeAndApply(Ogre::Entity* entity, + const SkinWeightsOptions& opts, + Algorithm algo) +{ + SkinWeightsReport report; + if (algo != Algorithm::InverseDistance) { + report.error = QStringLiteral("only InverseDistance backend is implemented"); + return report; + } + return applyToEntity(entity, opts); +} + +QJsonObject SkinWeights::reportToJson(const SkinWeightsReport& report) +{ + QJsonObject root; + root["meshName"] = report.meshName; + root["skeletonName"] = report.skeletonName; + root["applied"] = report.applied; + root["totalBones"] = report.totalBones; + root["totalVerticesProcessed"] = report.totalVerticesProcessed; + root["totalAssignmentsBefore"] = report.totalAssignmentsBefore; + root["totalAssignmentsAfter"] = report.totalAssignmentsAfter; + if (!report.error.isEmpty()) root["error"] = report.error; + + QJsonArray subs; + for (const auto& s : report.submeshes) { + QJsonObject obj; + obj["submeshIndex"] = s.submeshIndex; + obj["verticesProcessed"] = s.verticesProcessed; + obj["boneAssignmentsBefore"] = s.boneAssignmentsBefore; + obj["boneAssignmentsAfter"] = s.boneAssignmentsAfter; + obj["verticesWithMaxInfluences"] = s.verticesWithMaxInfluences; + subs.push_back(obj); + } + root["submeshes"] = subs; + return root; +} + +QString SkinWeights::reportToText(const SkinWeightsReport& report) +{ + QString out; + QTextStream s(&out); + s << "Skin Weights\n"; + s << "============\n"; + s << "Mesh: " << report.meshName << "\n"; + s << "Skeleton: " << report.skeletonName << "\n"; + s << "Bones: " << report.totalBones << "\n"; + s << "Vertices processed: " << report.totalVerticesProcessed << "\n"; + s << "Assignments: " << report.totalAssignmentsBefore + << " → " << report.totalAssignmentsAfter << "\n"; + if (!report.error.isEmpty()) s << "Error: " << report.error << "\n"; + return out; +} + +QString SkinWeights::algorithmToString(Algorithm algo) +{ + Q_UNUSED(algo); + return QStringLiteral("inverse-distance"); +} + +SkinWeights::Algorithm SkinWeights::algorithmFromString(const QString& s) +{ + // Only `InverseDistance` is implemented today. The + // recognized-string set widens here naturally when libigl + // BBW lands behind `-DENABLE_LIBIGL_BBW`. + Q_UNUSED(s); + return Algorithm::InverseDistance; +} diff --git a/src/SkinWeights.h b/src/SkinWeights.h new file mode 100644 index 00000000..c904ea3d --- /dev/null +++ b/src/SkinWeights.h @@ -0,0 +1,151 @@ +#ifndef SKIN_WEIGHTS_H +#define SKIN_WEIGHTS_H + +#include +#include +#include +#include + +namespace Ogre { + class Entity; + class Mesh; + class Skeleton; +} + +// Automatic skinning weights for a mesh + skeleton (issue #402, +// epic #397). +// +// The issue title proposes wrapping libigl's bounded biharmonic +// weights (BBW). BBW solves a constrained biharmonic equation over +// the *volume* of the mesh and produces the gold-standard smooth- +// skinning weights used by Blender / Maya / Houdini. The catch: +// +// 1. BBW requires a tetrahedral mesh of the volume — produced by +// TetGen, which is GPL/copyleft. Linking it forces the entire +// binary to GPL, which would close the door on Homebrew / +// Snap / WinGet redistribution under the project's current +// permissive-license stance. +// 2. TetGen meshing fails on common asset issues (non-manifold, +// self-intersection, degenerate tris). +// 3. Eigen + libigl headers add ~200 MB to the build tree. +// +// This first slice ships a **surface-based heuristic** with zero +// new dependencies. For each vertex, compute its distance to each +// bone segment (the line connecting the bone to its parent in the +// bind pose), invert it with an exponent (falloff), keep the top K +// bones, and normalize. This is the classic "closest point on +// bone" / "smooth bind" approach Maya and 3dsMax use as their +// default — it's heuristic and not as smooth as BBW, but it works +// out of the box on any character mesh including non-manifold +// FBX imports. +// +// A future slice can plug in libigl BBW behind an opt-in CMake +// flag (`-DENABLE_LIBIGL_BBW`) for users who accept the GPL +// implications, surfaced via `--algo biharmonic`. + +struct SkinWeightsOptions { + // Number of bones each vertex is allowed to be influenced by. + // The Ogre/Mixamo / Unity / Unreal hardware-skinning convention + // is 4. Lower = lighter shading, blockier deformation; higher + // = more memory and less GPU-friendly. + int maxInfluencesPerVertex = 4; + + // Inverse-distance exponent. Higher = sharper falloff (vertex + // gets glued tighter to its single nearest bone). Lower = + // smoother blending across bone boundaries. Range typically + // [1.5, 6.0]; default 4.0. + double falloff = 4.0; + + // Maximum distance ratio: a bone whose distance to the vertex + // is more than `maxInfluenceDistance * (mesh bounding-box + // diagonal)` is excluded. Stops a finger bone from getting any + // weight on a foot vertex. 0 disables the cap. + double maxInfluenceDistance = 0.5; + + // Skip bones with zero existing vertex weights when reading + // back results? Mixamo skeletons ship dozens of helper bones + // (twist bones, IK targets) that aren't actually skinned. + // When true, those bones are ignored. Default false (consider + // every bone) — set true on Mixamo-style rigs to avoid spurious + // weight on helper bones. + bool skipUnweightedBones = false; + + // When true (default), existing bone assignments are REPLACED. + // When false, new weights are merged with existing weights + // (existing weights take precedence on conflicting vertices). + // The merge case is useful for "fill in missing weights" + // workflows where part of the mesh is already manually skinned. + bool replaceExisting = true; +}; + +struct SkinWeightsSubmeshReport { + int submeshIndex = 0; + int verticesProcessed = 0; + int boneAssignmentsBefore = 0; + int boneAssignmentsAfter = 0; + int verticesWithMaxInfluences = 0; +}; + +struct SkinWeightsReport { + QString meshName; + QString skeletonName; + int totalBones = 0; + int totalVerticesProcessed = 0; + int totalAssignmentsBefore = 0; + int totalAssignmentsAfter = 0; + QList submeshes; + bool applied = false; + QString error; +}; + +class SkinWeights { +public: + enum class Algorithm { + InverseDistance, // Default — closest-point-on-bone heuristic. + // Future: + // Biharmonic — libigl BBW (requires TetGen → GPL opt-in) + }; + + // Compute new skin weights for `entity` against its attached + // skeleton and commit them to the mesh. Replaces (or merges + // with — see options) the existing bone-assignment list and + // calls `_compileBoneAssignments` to refresh the hardware + // blend buffer. + // + // The entity MUST have a skeleton attached. Static (skeleton- + // less) entities return `applied=false` with an error. + static SkinWeightsReport computeAndApply(Ogre::Entity* entity, + const SkinWeightsOptions& opts = {}, + Algorithm algo = Algorithm::InverseDistance); + + // Pure-data variant: bone segments + vertex positions → + // sparse weight list (per vertex: K (bone_index, weight) + // pairs). Used by tests and any future headless caller. + // + // `bones` provides one entry per bone giving its world-space + // head and tail in the bind pose. Leaf / root bones may pass + // `tail == head` — the algorithm falls back to point-to-point + // distance in that case. + struct BoneSegment { + double headX, headY, headZ; + double tailX, tailY, tailZ; + }; + struct VertexWeights { + int boneIndices[8] = { -1, -1, -1, -1, -1, -1, -1, -1 }; + double weights[8] = { 0, 0, 0, 0, 0, 0, 0, 0 }; + int count = 0; + }; + static bool computeWeights(const float* vertexPositions, + int vertexCount, + const std::vector& bones, + const SkinWeightsOptions& opts, + std::vector& outWeights); + + static QJsonObject reportToJson(const SkinWeightsReport& report); + static QString reportToText(const SkinWeightsReport& report); + + static QString algorithmToString(Algorithm algo); + static Algorithm algorithmFromString(const QString& s); +}; + +#endif // SKIN_WEIGHTS_H diff --git a/src/SkinWeightsController.cpp b/src/SkinWeightsController.cpp new file mode 100644 index 00000000..be3028f5 --- /dev/null +++ b/src/SkinWeightsController.cpp @@ -0,0 +1,171 @@ +#include "SkinWeightsController.h" +#include "SkinWeights.h" +#include "SelectionSet.h" +#include "SentryReporter.h" +#include "UndoManager.h" +#include "commands/ComputeSkinWeightsCommand.h" + +#include +#include +#include + +SkinWeightsController* SkinWeightsController::m_pSingleton = nullptr; + +SkinWeightsController* SkinWeightsController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new SkinWeightsController(); + return m_pSingleton; +} + +SkinWeightsController* SkinWeightsController::qmlInstance(QQmlEngine* engine, QJSEngine*) +{ + Q_UNUSED(engine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void SkinWeightsController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +SkinWeightsController::SkinWeightsController() : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &SkinWeightsController::selectionChanged); +} + +bool SkinWeightsController::hasSkinnedSelection() 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; + return first->getMesh()->getSkeleton() != nullptr; +} + +QVariantMap SkinWeightsController::computeWeightsForSelected(int maxInfluencesPerVertex, + double falloff, + double maxInfluenceDistance, + bool skipUnweightedBones, + bool replaceExisting) +{ + QVariantMap result; + + // Breadcrumb the UI action up front so failed attempts (no + // selection, no skeleton, etc.) still reach Sentry — the + // per-operation `ai.assist.skin_weights` breadcrumb below is + // only emitted once we've passed validation. + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Compute Skin Weights requested")); + + auto* sel = SelectionSet::getSingleton(); + if (!sel) { + const auto msg = QStringLiteral("No selection set."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + const auto entities = sel->getResolvedEntities(); + 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(); + // `getResolvedEntities()` may return a stale / null first entry + // (e.g. selection resolved against an entity that was just + // destroyed). `hasSkinnedSelection()` already treats it as + // nullable; guard here too so we return a user-facing error + // instead of crashing on `entity->getName()` / computeAndApply. + 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; + } + // Pre-check the skeleton here so a skeleton-less mesh fails + // cleanly WITHOUT leaving a no-op entry on the undo stack + // (QUndoStack::push runs the command's redo() unconditionally). + if (!entity->getMesh()->getSkeleton()) { + const auto msg = QStringLiteral("Mesh has no skeleton attached."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = maxInfluencesPerVertex; + opts.falloff = falloff; + opts.maxInfluenceDistance = maxInfluenceDistance; + opts.skipUnweightedBones = skipUnweightedBones; + opts.replaceExisting = replaceExisting; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.skin_weights"), + QString("UI skin entity=%1 maxInf=%2 falloff=%3 maxDist=%4") + .arg(QString::fromStdString(entity->getName())) + .arg(maxInfluencesPerVertex) + .arg(falloff).arg(maxInfluenceDistance)); + + m_busy = true; + emit busyChanged(); + + SkinWeightsReport report; + try { + // Run through an undo command so the auto-skin can be + // reverted with Ctrl+Z. The command snapshots the pre-skin + // bone assignments, runs `computeAndApply` on its first + // redo, and restores the snapshot on undo. We construct it, + // push it (which executes redo() synchronously), then read + // the report it captured. + auto* cmd = new ComputeSkinWeightsCommand( + entity->getName(), opts); + UndoManager::getSingleton()->push(cmd); + report = cmd->report(); + } 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(); + + result["applied"] = report.applied; + result["meshName"] = report.meshName; + result["skeletonName"] = report.skeletonName; + result["totalBones"] = report.totalBones; + result["totalVerticesProcessed"] = report.totalVerticesProcessed; + result["totalAssignmentsBefore"] = report.totalAssignmentsBefore; + result["totalAssignmentsAfter"] = report.totalAssignmentsAfter; + + if (report.applied) { + emit weightsApplied(result); + } else { + // Always populate `error` in the result map, not just when + // the report carries one — otherwise SkinWeightsDialog's + // synchronous return path overwrites the emitted message + // with "unknown error". + const QString msg = report.error.isEmpty() + ? QStringLiteral("Skin weights failed") + : report.error; + result["error"] = msg; + emit error(msg); + } + + return result; +} diff --git a/src/SkinWeightsController.h b/src/SkinWeightsController.h new file mode 100644 index 00000000..66460b65 --- /dev/null +++ b/src/SkinWeightsController.h @@ -0,0 +1,57 @@ +#ifndef SKIN_WEIGHTS_CONTROLLER_H +#define SKIN_WEIGHTS_CONTROLLER_H + +#include +#include +#include + +// QML-facing singleton for automatic skin weights (issue #402). +// Wraps `SkinWeights::computeAndApply` plus selection + skeleton- +// presence state so the Inspector button can disable itself when +// no skinned mesh is selected. +class SkinWeightsController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool hasSkinnedSelection READ hasSkinnedSelection NOTIFY selectionChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + +public: + static SkinWeightsController* instance(); + static SkinWeightsController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /// True when the currently selected entity has a skeleton + /// attached. The button binds to this so it disables on + /// static-mesh selections (where computing weights is a + /// no-op). + bool hasSkinnedSelection() const; + bool busy() const { return m_busy; } + + /// Compute and apply skin weights to the first resolved + /// selected entity. Returns a QVariantMap mirroring + /// `SkinWeightsReport`. Emits `weightsApplied(report)` on + /// success or `error(msg)` on failure. + Q_INVOKABLE QVariantMap computeWeightsForSelected(int maxInfluencesPerVertex, + double falloff, + double maxInfluenceDistance, + bool skipUnweightedBones, + bool replaceExisting); + +signals: + void selectionChanged(); + void busyChanged(); + void weightsApplied(const QVariantMap& report); + void error(const QString& message); + +private: + SkinWeightsController(); + ~SkinWeightsController() override = default; + + static SkinWeightsController* m_pSingleton; + bool m_busy = false; +}; + +#endif // SKIN_WEIGHTS_CONTROLLER_H diff --git a/src/SkinWeights_test.cpp b/src/SkinWeights_test.cpp new file mode 100644 index 00000000..270bf69d --- /dev/null +++ b/src/SkinWeights_test.cpp @@ -0,0 +1,351 @@ +#include "SkinWeights.h" + +#include + +#include +#include + +#include +#include + +// Unit tests for the inverse-distance skin weights (issue #402). +// Only the pure-data `computeWeights` path is covered here; the +// Ogre-backed `computeAndApply` requires a live entity + skeleton +// and is exercised by integration runs. + +namespace { + +// A vertical bar: 4 vertices at z = 0, 1, 2, 3 along the y axis. +// Bone 0 spans y=[0..1], bone 1 spans y=[2..3]. Expected outcome: +// vertices 0 and 1 weight strongly to bone 0; verts 2 and 3 to +// bone 1. +std::vector kBarPositions = { + 0.0f, 0.0f, 0.0f, + 0.0f, 1.0f, 0.0f, + 0.0f, 2.0f, 0.0f, + 0.0f, 3.0f, 0.0f, +}; + +std::vector kTwoBones = { + { 0.0, 0.0, 0.0, 0.0, 1.0, 0.0 }, // bone 0: y=[0..1] + { 0.0, 2.0, 0.0, 0.0, 3.0, 0.0 }, // bone 1: y=[2..3] +}; + +} // namespace + +TEST(SkinWeightsTest, NearVerticesGetCorrectBone) +{ + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 2; + opts.maxInfluenceDistance = 0; // no cap so every bone is considered + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, opts, w)); + ASSERT_EQ(w.size(), 4u); + + // Vertex 0 sits on bone 0; should be ~100% bone 0. + EXPECT_GE(w[0].count, 1); + EXPECT_EQ(w[0].boneIndices[0], 0); + EXPECT_GT(w[0].weights[0], 0.99); + + // Vertex 3 sits on bone 1. + EXPECT_EQ(w[3].boneIndices[0], 1); + EXPECT_GT(w[3].weights[0], 0.99); +} + +TEST(SkinWeightsTest, WeightsSumToOne) +{ + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 4; + opts.maxInfluenceDistance = 0; + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, opts, w)); + + for (const auto& vw : w) { + double sum = 0.0; + for (int i = 0; i < vw.count; ++i) sum += vw.weights[i]; + EXPECT_NEAR(sum, 1.0, 1e-6) + << "vertex weights don't sum to 1; count=" << vw.count; + } +} + +TEST(SkinWeightsTest, NonNegativeWeights) +{ + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 4; + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, opts, w)); + + for (const auto& vw : w) { + for (int i = 0; i < vw.count; ++i) + EXPECT_GE(vw.weights[i], 0.0); + } +} + +TEST(SkinWeightsTest, MaxInfluencesIsRespected) +{ + // 5 bones at different y positions, max influences clamped to 2. + std::vector bones; + for (int i = 0; i < 5; ++i) { + const double y = i * 1.0; + bones.push_back({ 0, y, 0, 0, y + 0.5, 0 }); + } + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 2; + opts.maxInfluenceDistance = 0; + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, bones, opts, w)); + + for (const auto& vw : w) { + EXPECT_LE(vw.count, 2) + << "more than 2 influences leaked through despite maxInfluencesPerVertex=2"; + } +} + +TEST(SkinWeightsTest, MaxDistanceCapExcludesFarBones) +{ + // Bar from y=0..3, plus a bone far away at y=100. + std::vector bones = kTwoBones; + bones.push_back({ 0, 100, 0, 0, 101, 0 }); + + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 4; + opts.maxInfluenceDistance = 0.5; // half the diagonal (~1.5 units) + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, bones, opts, w)); + + for (const auto& vw : w) { + for (int i = 0; i < vw.count; ++i) { + EXPECT_NE(vw.boneIndices[i], 2) + << "the far bone 2 should have been distance-capped out"; + } + } +} + +TEST(SkinWeightsTest, FalloffSharpensTheBind) +{ + // With a high falloff, the weights should concentrate more + // aggressively on the nearest bone. + std::vector low, high; + + SkinWeightsOptions optsLow; + optsLow.maxInfluencesPerVertex = 2; + optsLow.falloff = 1.0; + optsLow.maxInfluenceDistance = 0; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, optsLow, low)); + + SkinWeightsOptions optsHigh = optsLow; + optsHigh.falloff = 8.0; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, optsHigh, high)); + + // Vertex 1 sits at y=1 — equidistant-ish from both bones. + // High falloff should drive its primary weight higher. + ASSERT_GE(low.size(), 2u); + ASSERT_GE(high.size(), 2u); + ASSERT_GE(low[1].count, 1); + ASSERT_GE(high[1].count, 1); + EXPECT_GE(high[1].weights[0], low[1].weights[0]) + << "high falloff failed to concentrate weight on the nearest bone"; +} + +TEST(SkinWeightsTest, EmptyInputReturnsFalse) +{ + SkinWeightsOptions opts; + std::vector w; + EXPECT_FALSE(SkinWeights::computeWeights(nullptr, 0, {}, opts, w)); + EXPECT_TRUE(w.empty()); +} + +TEST(SkinWeightsTest, AlgorithmStringRoundTrip) +{ + EXPECT_EQ(SkinWeights::algorithmToString( + SkinWeights::Algorithm::InverseDistance), + QStringLiteral("inverse-distance")); + EXPECT_EQ(SkinWeights::algorithmFromString("inverse-distance"), + SkinWeights::Algorithm::InverseDistance); + EXPECT_EQ(SkinWeights::algorithmFromString("unknown-fallback"), + SkinWeights::Algorithm::InverseDistance); +} + +// ─── Edge cases ────────────────────────────────────────────────────────────── + +TEST(SkinWeightsTest, SingleBoneGetsFullWeight) +{ + // One bone, several verts — every vertex should be 100% bone 0. + std::vector oneBone = { + { 0.0, 0.0, 0.0, 0.0, 3.0, 0.0 }, + }; + SkinWeightsOptions opts; + opts.maxInfluenceDistance = 0; // no cap + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, oneBone, opts, w)); + for (const auto& vw : w) { + ASSERT_EQ(vw.count, 1); + EXPECT_EQ(vw.boneIndices[0], 0); + EXPECT_NEAR(vw.weights[0], 1.0, 1e-9); + } +} + +TEST(SkinWeightsTest, LeafBonePointDistanceWorks) +{ + // A bone with head == tail degenerates to point distance. Verify + // the vertex closest to the point gets the dominant weight. + std::vector bones = { + { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 }, // point at origin (leaf) + { 0.0, 3.0, 0.0, 0.0, 3.0, 0.0 }, // point at y=3 (leaf) + }; + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 2; + opts.maxInfluenceDistance = 0; + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, bones, opts, w)); + // Vertex 0 (y=0) closest to bone 0; vertex 3 (y=3) closest to bone 1. + EXPECT_EQ(w[0].boneIndices[0], 0); + EXPECT_EQ(w[3].boneIndices[0], 1); +} + +TEST(SkinWeightsTest, VertexExactlyOnBoneDoesNotDivideByZero) +{ + // Vertex 0 sits exactly on bone 0's head. The eps in the + // inverse-distance formula must keep the weight finite and + // normalized — not NaN/Inf. + std::vector w; + SkinWeightsOptions opts; + opts.maxInfluenceDistance = 0; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, opts, w)); + for (const auto& vw : w) { + for (int i = 0; i < vw.count; ++i) { + EXPECT_TRUE(std::isfinite(vw.weights[i])) + << "weight is NaN/Inf — eps guard failed"; + } + } +} + +TEST(SkinWeightsTest, VertexOutsideAllRadiiPinsToBoneZero) +{ + // A lone vertex far from every bone, with a tight distance cap, + // should fall through to the "pin to bone 0, weight 1.0" + // fallback rather than ending up with zero influences (which + // would leave it static while the rig animates). + std::vector farVert = { 1000.0f, 1000.0f, 1000.0f }; + SkinWeightsOptions opts; + opts.maxInfluenceDistance = 0.01; // tiny cap → excludes the bar bones + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + farVert.data(), 1, kTwoBones, opts, w)); + ASSERT_EQ(w.size(), 1u); + EXPECT_EQ(w[0].count, 1); + EXPECT_EQ(w[0].boneIndices[0], 0); + EXPECT_NEAR(w[0].weights[0], 1.0, 1e-9); +} + +TEST(SkinWeightsTest, MaxInfluencesClampedToUpperBound) +{ + // Request more than the hard cap of 8 — should be clamped, not + // overflow the fixed-size VertexWeights arrays. + std::vector bones; + for (int i = 0; i < 12; ++i) { + const double y = i * 0.25; + bones.push_back({ 0, y, 0, 0, y + 0.1, 0 }); + } + SkinWeightsOptions opts; + opts.maxInfluencesPerVertex = 999; // absurd — must clamp to 8 + opts.maxInfluenceDistance = 0; + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, bones, opts, w)); + for (const auto& vw : w) + EXPECT_LE(vw.count, 8) << "influence count exceeded the hard cap of 8"; +} + +// ─── Report serialization ──────────────────────────────────────────────────── + +TEST(SkinWeightsTest, ReportToJsonRoundTrip) +{ + SkinWeightsReport report; + report.meshName = QStringLiteral("Hero"); + report.skeletonName = QStringLiteral("Hero.skeleton"); + report.totalBones = 30; + report.totalVerticesProcessed = 1200; + report.totalAssignmentsBefore = 0; + report.totalAssignmentsAfter = 4800; + report.applied = true; + + SkinWeightsSubmeshReport sub; + sub.submeshIndex = 0; + sub.verticesProcessed = 1200; + sub.boneAssignmentsBefore = 0; + sub.boneAssignmentsAfter = 4800; + sub.verticesWithMaxInfluences = 1100; + report.submeshes.push_back(sub); + + const auto json = SkinWeights::reportToJson(report); + EXPECT_EQ(json["meshName"].toString(), QStringLiteral("Hero")); + EXPECT_EQ(json["skeletonName"].toString(), QStringLiteral("Hero.skeleton")); + EXPECT_EQ(json["totalBones"].toInt(), 30); + EXPECT_EQ(json["totalVerticesProcessed"].toInt(), 1200); + EXPECT_EQ(json["totalAssignmentsAfter"].toInt(), 4800); + EXPECT_TRUE(json["applied"].toBool()); + ASSERT_TRUE(json["submeshes"].isArray()); + const auto subs = json["submeshes"].toArray(); + ASSERT_EQ(subs.size(), 1); + EXPECT_EQ(subs[0].toObject()["verticesWithMaxInfluences"].toInt(), 1100); +} + +TEST(SkinWeightsTest, ReportToJsonIncludesErrorOnFailure) +{ + SkinWeightsReport report; + report.applied = false; + report.error = QStringLiteral("mesh has no skeleton attached"); + const auto json = SkinWeights::reportToJson(report); + EXPECT_FALSE(json["applied"].toBool()); + EXPECT_EQ(json["error"].toString(), + QStringLiteral("mesh has no skeleton attached")); +} + +TEST(SkinWeightsTest, ReportToTextContainsKeyFields) +{ + SkinWeightsReport report; + report.meshName = QStringLiteral("Hero"); + report.skeletonName = QStringLiteral("Hero.skeleton"); + report.totalBones = 30; + report.totalVerticesProcessed = 1200; + report.totalAssignmentsBefore = 100; + report.totalAssignmentsAfter = 4800; + report.applied = true; + + const QString txt = SkinWeights::reportToText(report); + EXPECT_TRUE(txt.contains("Hero")); + EXPECT_TRUE(txt.contains("Hero.skeleton")); + EXPECT_TRUE(txt.contains("30")); + EXPECT_TRUE(txt.contains("1200")); + EXPECT_TRUE(txt.contains("100")); + EXPECT_TRUE(txt.contains("4800")); +} + +TEST(SkinWeightsTest, FalloffClampedFromBelow) +{ + // A falloff below the 0.5 floor must not throw or produce + // garbage — computeWeights clamps it internally. Verify the + // result is still a valid normalized weight set. + SkinWeightsOptions opts; + opts.falloff = 0.0; // below floor + opts.maxInfluenceDistance = 0; + std::vector w; + ASSERT_TRUE(SkinWeights::computeWeights( + kBarPositions.data(), 4, kTwoBones, opts, w)); + for (const auto& vw : w) { + double sum = 0.0; + for (int i = 0; i < vw.count; ++i) sum += vw.weights[i]; + EXPECT_NEAR(sum, 1.0, 1e-6); + } +} diff --git a/src/commands/ComputeSkinWeightsCommand.cpp b/src/commands/ComputeSkinWeightsCommand.cpp new file mode 100644 index 00000000..1c1885e7 --- /dev/null +++ b/src/commands/ComputeSkinWeightsCommand.cpp @@ -0,0 +1,132 @@ +#include "commands/ComputeSkinWeightsCommand.h" +#include "Manager.h" + +#include +#include +#include +#include + +ComputeSkinWeightsCommand::ComputeSkinWeightsCommand(std::string entityName, + SkinWeightsOptions opts, + QUndoCommand* parent) + : QUndoCommand(parent) + , mEntityName(std::move(entityName)) + , mOpts(opts) +{ + setText(QStringLiteral("Compute Skin Weights")); +} + +Ogre::Entity* ComputeSkinWeightsCommand::resolveEntity() const +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return nullptr; + // Manager::getEntities() already filters to real Ogre::Entity + // objects at collection time (collectEntitiesRecursive only + // appends attached objects whose getMovableType() == "Entity"), + // so every element here is a genuine Entity — the + // getMovableType() re-check below is a belt-and-suspenders + // guard, not load-bearing. + for (Ogre::Entity* e : mgr->getEntities()) { + if (e && e->getMovableType() == "Entity" + && e->getName() == mEntityName) + return e; + } + return nullptr; +} + +void ComputeSkinWeightsCommand::captureSnapshot( + Ogre::Mesh* mesh, std::vector& out) const +{ + out.clear(); + if (!mesh) return; + + // Mesh-level (shared-vertex) assignments — owner index -1. + bool anyShared = false; + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + if (mesh->getSubMesh(si) && mesh->getSubMesh(si)->useSharedVertices) { + anyShared = true; + break; + } + } + if (anyShared) { + OwnerSnapshot s; + s.submeshIndex = -1; + for (const auto& kv : mesh->getBoneAssignments()) + s.assignments.insert(kv); + out.push_back(std::move(s)); + } + + // Per-submesh (non-shared) assignments. + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (!sub || sub->useSharedVertices) continue; + OwnerSnapshot s; + s.submeshIndex = si; + for (const auto& kv : sub->getBoneAssignments()) + s.assignments.insert(kv); + out.push_back(std::move(s)); + } +} + +void ComputeSkinWeightsCommand::restoreSnapshot( + Ogre::Mesh* mesh, const std::vector& snap) const +{ + if (!mesh) return; + for (const auto& s : snap) { + if (s.submeshIndex < 0) { + mesh->clearBoneAssignments(); + for (const auto& kv : s.assignments) + mesh->addBoneAssignment(kv.second); + // _compileBoneAssignments re-packs BLEND_INDICES / + // BLEND_WEIGHTS into the (unchanged) shared vertex buffer + // and rebuilds sharedBlendIndexToBoneIndexMap from the + // restored list. Unlike the UV-unwrap restore — which + // swapped the entire VertexData out from under a live + // SkeletonInstance — here the buffer object is the same + // one the skeleton already references, so recompiling is + // both correct and safe: it just rewrites the blend + // bytes in place to match the restored weights. + mesh->_compileBoneAssignments(); + } else if (s.submeshIndex < static_cast(mesh->getNumSubMeshes())) { + Ogre::SubMesh* sub = mesh->getSubMesh( + static_cast(s.submeshIndex)); + if (!sub) continue; + sub->clearBoneAssignments(); + for (const auto& kv : s.assignments) + sub->addBoneAssignment(kv.second); + sub->_compileBoneAssignments(); + } + } +} + +void ComputeSkinWeightsCommand::redo() +{ + Ogre::Entity* entity = resolveEntity(); + if (!entity || !entity->getMesh()) { + mReport.applied = false; + mReport.error = QStringLiteral("entity not found / no mesh"); + return; + } + Ogre::Mesh* mesh = entity->getMesh().get(); + + if (!mCaptured) { + // First execution: snapshot the pre-skin weights, run the + // compute, then snapshot the post-skin weights for replay. + captureSnapshot(mesh, mBefore); + mReport = SkinWeights::computeAndApply(entity, mOpts); + captureSnapshot(mesh, mAfter); + mCaptured = true; + } else { + // Subsequent redo (after an undo): replay the captured + // "after" state without recomputing. + restoreSnapshot(mesh, mAfter); + } +} + +void ComputeSkinWeightsCommand::undo() +{ + if (!mCaptured) return; + Ogre::Entity* entity = resolveEntity(); + if (!entity || !entity->getMesh()) return; + restoreSnapshot(entity->getMesh().get(), mBefore); +} diff --git a/src/commands/ComputeSkinWeightsCommand.h b/src/commands/ComputeSkinWeightsCommand.h new file mode 100644 index 00000000..452b7ee5 --- /dev/null +++ b/src/commands/ComputeSkinWeightsCommand.h @@ -0,0 +1,82 @@ +#ifndef COMPUTE_SKIN_WEIGHTS_COMMAND_H +#define COMPUTE_SKIN_WEIGHTS_COMMAND_H + +#include +#include + +#include "SkinWeights.h" + +#include +#include + +namespace Ogre { + class Entity; + class Mesh; + struct VertexBoneAssignment; +} + +/** + * Undoable wrapper around `SkinWeights::computeAndApply` (issue #402 + * follow-up). The auto-skin operation rewrites every submesh's bone + * assignments (and, for shared-vertex meshes, the mesh-level list); + * this command snapshots all of those lists plus the + * `blendIndexToBoneIndexMap`s before the first redo so undo restores + * the exact pre-skin weights. + * + * `redo()` runs the compute (first time) or replays the captured + * "after" state (subsequent redos). `undo()` pastes the "before" + * snapshot back. Both paths reinstall the index maps directly and + * deliberately do NOT call `_compileBoneAssignments` on restore — + * the captured BLEND_INDICES/WEIGHTS bytes in the vertex buffer are + * already consistent with the snapshot, and recompiling against a + * live `SkeletonInstance` would shatter the on-screen mesh (same + * hazard the UV-unwrap restore documents). + * + * The command targets the entity by name so it survives scene + * rebuilds the way the other entity-scoped commands do. + */ +class ComputeSkinWeightsCommand : public QUndoCommand +{ +public: + ComputeSkinWeightsCommand(std::string entityName, + SkinWeightsOptions opts, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + /// The report produced by the first `redo()`. Lets the + /// controller surface "N bones, M verts, X assignments" to the + /// UI after pushing the command. + const SkinWeightsReport& report() const { return mReport; } + bool applied() const { return mReport.applied; } + +private: + // One snapshot per assignment owner. `submeshIndex == -1` means + // the mesh-level (shared-vertex) list. We only snapshot the + // assignment list itself — `restoreSnapshot` calls + // `_compileBoneAssignments`, which rebuilds the + // blendIndexToBoneIndexMap and re-packs the vertex buffer's + // BLEND bytes from that list, so the index map need not be + // captured separately. + struct OwnerSnapshot { + int submeshIndex = 0; + std::multimap assignments; + }; + + Ogre::Entity* resolveEntity() const; + void captureSnapshot(Ogre::Mesh* mesh, + std::vector& out) const; + void restoreSnapshot(Ogre::Mesh* mesh, + const std::vector& snap) const; + + std::string mEntityName; + SkinWeightsOptions mOpts; + SkinWeightsReport mReport; + + std::vector mBefore; // pre-skin weights + std::vector mAfter; // post-skin weights (for redo replay) + bool mCaptured = false; +}; + +#endif // COMPUTE_SKIN_WEIGHTS_COMMAND_H diff --git a/src/main.cpp b/src/main.cpp index a6d78be7..74e2482b 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -93,7 +93,7 @@ int main(int argc, char *argv[]) || arg == "decimate" || arg == "atlas" || arg == "atlas-apply" || arg == "optimize" || arg == "bake-vertex-colors" || arg == "vat" || arg == "uv" || arg == "retopo" - || arg == "morph" || arg == "nodeanim") + || arg == "skin" || arg == "morph" || arg == "nodeanim") cliMode = true; break; // first non-flag arg determines mode } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 83b9a0d5..17c3cae7 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -81,6 +81,7 @@ #include "MeshValidator.h" #include "UvUnwrapController.h" #include "QuadRetopoController.h" +#include "SkinWeightsController.h" #include "MaterialPresetLibrary.h" #include "MaterialPreviewRenderer.h" #include "AIChatManager.h" @@ -395,6 +396,7 @@ MainWindow::~MainWindow() MeshLodController::kill(); UvUnwrapController::kill(); QuadRetopoController::kill(); + SkinWeightsController::kill(); MeshValidator::kill(); MaterialPresetLibrary::kill(); MaterialPreviewRenderer::kill(); @@ -563,6 +565,11 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return QuadRetopoController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "SkinWeightsController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return SkinWeightsController::qmlInstance(engine, nullptr); + }); // Open the LOD export directory picker from MainWindow so the dialog has a // proper parent widget — QFileDialog invoked from a QML context doesn't // reliably appear on macOS without a valid parent QWidget. diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 3b760858..0e11d047 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -11,6 +11,7 @@ ../qml/ApplyAtlasDialog.qml ../qml/UvUnwrapDialog.qml ../qml/QuadRetopoDialog.qml + ../qml/SkinWeightsDialog.qml ../qml/qmldir ../qml/ThemedButton.qml ../qml/ThemedCheckBox.qml diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fdfc00df..3405f8a2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,6 +106,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/NodeAnimCommands.cpp ${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/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp @@ -118,6 +119,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/UvUnwrapController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QuadRetopo.cpp ${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/MeshOptimizerLod.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ExportOptimizer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/UvUnwrap.cpp