From a9c0c3272477c36ad9c4c2a337377e57f7555b10 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 11:40:12 -0400 Subject: [PATCH 01/24] feat(#407): native auto-rig core + CLI rig subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinocchio (Baran & Popović 2007) is LGPL-2.1, which conflicts with the project's statically-linked permissive-distribution stance — so, like #401 (Instant Meshes) and #402 (libigl/TetGen), this is a native from-scratch implementation of the published *algorithm* (skeleton-template embedding), zero new deps. - AutoRig (src/AutoRig.h/.cpp): Ogre-free pure-data core — built-in templates (humanoid 19-bone / biped / quadruped / generic), fitTemplate() maps a template's normalised joint graph into the mesh AABB then recentres flagged joints toward per-height-slab centroids (spine→medial line, limb roots inside the silhouette). rigEntity() builds an Ogre::Skeleton (parent-relative bone positions, setBindingPose), binds via mesh->_notifySkeleton + entity ->_initialise(true) — the _initialise is REQUIRED or the exporters (both gate on entity->hasSkeleton()) silently drop the new rig. - AutoRigController (QML singleton, mirrors SkinWeightsController) for the GUI. - CLI: `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (cmdRig) — import, rig, optionally chain SkinWeights::computeAndApply, export. Registered in run() dispatch + AppLaunchHandler subcommand list. - AutoRig_test.cpp: pure-data unit tests (template well-formedness, AABB containment, vertical ordering, degenerate-input robustness, string/JSON). - Sentry breadcrumb ai.assist.auto_rig. Verified end-to-end: static OBJ -> 19-bone humanoid + skin -> glTF export with 1 skin / 17 joints; FBX export carries the skeleton too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AppLaunchHandler.cpp | 4 +- src/AutoRig.cpp | 384 ++++++++++++++++++++++++++++++++++++++ src/AutoRig.h | 138 ++++++++++++++ src/AutoRigController.cpp | 131 +++++++++++++ src/AutoRigController.h | 54 ++++++ src/AutoRig_test.cpp | 157 ++++++++++++++++ src/CLIPipeline.cpp | 119 ++++++++++++ src/CLIPipeline.h | 5 + src/CMakeLists.txt | 4 + 9 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 src/AutoRig.cpp create mode 100644 src/AutoRig.h create mode 100644 src/AutoRigController.cpp create mode 100644 src/AutoRigController.h create mode 100644 src/AutoRig_test.cpp diff --git a/src/AppLaunchHandler.cpp b/src/AppLaunchHandler.cpp index b38a02926..b9c3e8be8 100644 --- a/src/AppLaunchHandler.cpp +++ b/src/AppLaunchHandler.cpp @@ -26,8 +26,8 @@ bool isCliSubcommand(const QString& arg) QStringLiteral("decimate"), QStringLiteral("atlas"), QStringLiteral("atlas-apply"), QStringLiteral("optimize"), QStringLiteral("bake-vertex-colors"), QStringLiteral("vat"), QStringLiteral("uv"), QStringLiteral("retopo"), - QStringLiteral("skin"), QStringLiteral("morph"), QStringLiteral("nodeanim"), - QStringLiteral("cloud"), + QStringLiteral("skin"), QStringLiteral("rig"), QStringLiteral("morph"), + QStringLiteral("nodeanim"), QStringLiteral("cloud"), }; return kSubcommands.contains(arg); } diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp new file mode 100644 index 000000000..7cdb2abd3 --- /dev/null +++ b/src/AutoRig.cpp @@ -0,0 +1,384 @@ +#include "AutoRig.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// A template joint literal: name, parent index, normalised x/y/z in +// [0,1]^3 (y = up), and whether the refinement step recentres it. +struct TJ { const char* name; int parent; double x, y, z; bool recenter; }; + +// --- Skeleton templates ----------------------------------------------------- +// +// Positions are in a normalised unit box: x in [0,1] left→right, y in +// [0,1] down→up, z in [0,1] back→front. The mesh's actual up axis is +// remapped from +Y at fit time via Options::upAxis. 0.5 is centre. + +// Humanoid (≈ Mixamo-lite): pelvis → spine → chest → neck → head, plus +// symmetric shoulder/arm and hip/leg chains. Limb tips keep their +// proportional position (recenter=false) so they reach to the silhouette. +const TJ kHumanoid[] = { + {"Hips", -1, 0.50, 0.52, 0.50, true}, + {"Spine", 0, 0.50, 0.62, 0.50, true}, + {"Chest", 1, 0.50, 0.72, 0.50, true}, + {"Neck", 2, 0.50, 0.84, 0.50, true}, + {"Head", 3, 0.50, 0.92, 0.50, true}, + // Left arm (model's left = +x). + {"LeftShoulder", 2, 0.60, 0.78, 0.50, true}, + {"LeftArm", 5, 0.70, 0.78, 0.50, false}, + {"LeftForeArm", 6, 0.82, 0.78, 0.50, false}, + {"LeftHand", 7, 0.93, 0.78, 0.50, false}, + // Right arm (-x). + {"RightShoulder",2, 0.40, 0.78, 0.50, true}, + {"RightArm", 9, 0.30, 0.78, 0.50, false}, + {"RightForeArm",10, 0.18, 0.78, 0.50, false}, + {"RightHand", 11, 0.07, 0.78, 0.50, false}, + // Left leg. + {"LeftUpLeg", 0, 0.58, 0.50, 0.50, true}, + {"LeftLeg", 13, 0.58, 0.27, 0.50, false}, + {"LeftFoot", 14, 0.58, 0.04, 0.55, false}, + // Right leg. + {"RightUpLeg", 0, 0.42, 0.50, 0.50, true}, + {"RightLeg", 16, 0.42, 0.27, 0.50, false}, + {"RightFoot", 17, 0.42, 0.04, 0.55, false}, +}; + +// Biped: spine + 2 legs + short arm stubs (simpler/cheaper than humanoid). +const TJ kBiped[] = { + {"Hips", -1, 0.50, 0.52, 0.50, true}, + {"Spine", 0, 0.50, 0.68, 0.50, true}, + {"Head", 1, 0.50, 0.90, 0.50, true}, + {"LeftArm", 1, 0.68, 0.74, 0.50, false}, + {"RightArm", 1, 0.32, 0.74, 0.50, false}, + {"LeftUpLeg", 0, 0.58, 0.50, 0.50, true}, + {"LeftFoot", 5, 0.58, 0.04, 0.55, false}, + {"RightUpLeg", 0, 0.42, 0.50, 0.50, true}, + {"RightFoot", 7, 0.42, 0.04, 0.55, false}, +}; + +// Quadruped: a horizontal spine (front→back along +z), 4 legs, head, tail. +// Body lies low; "up" is still +y. Front of the body = high z. +const TJ kQuadruped[] = { + {"SpineFront", -1, 0.50, 0.55, 0.70, true}, + {"SpineMid", 0, 0.50, 0.55, 0.50, true}, + {"SpineBack", 1, 0.50, 0.55, 0.30, true}, + {"Neck", 0, 0.50, 0.62, 0.82, true}, + {"Head", 3, 0.50, 0.66, 0.95, true}, + {"Tail", 2, 0.50, 0.55, 0.08, false}, + // Front legs (high z). + {"FrontLeftUpLeg", 0, 0.62, 0.45, 0.72, true}, + {"FrontLeftFoot", 6, 0.62, 0.04, 0.72, false}, + {"FrontRightUpLeg", 0, 0.38, 0.45, 0.72, true}, + {"FrontRightFoot", 8, 0.38, 0.04, 0.72, false}, + // Back legs (low z). + {"BackLeftUpLeg", 2, 0.62, 0.45, 0.30, true}, + {"BackLeftFoot", 10, 0.62, 0.04, 0.30, false}, + {"BackRightUpLeg", 2, 0.38, 0.45, 0.30, true}, + {"BackRightFoot", 12, 0.38, 0.04, 0.30, false}, +}; + +// Generic fallback: a 3-joint vertical spine. Always succeeds. +const TJ kGeneric[] = { + {"Root", -1, 0.50, 0.05, 0.50, true}, + {"Spine", 0, 0.50, 0.50, 0.50, true}, + {"Top", 1, 0.50, 0.95, 0.50, true}, +}; + +std::vector toJoints(const TJ* arr, size_t n) +{ + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + AutoRig::Joint j; + j.name = QString::fromUtf8(arr[i].name); + j.parent = arr[i].parent; + j.pos = {arr[i].x, arr[i].y, arr[i].z}; + j.recenter = arr[i].recenter; + out.push_back(std::move(j)); + } + return out; +} + +} // namespace + +// Out-of-line so the {} default args on the static methods resolve to a +// constructor call (not class-definition-time aggregate init). The member +// initializers in the header supply the actual default values. +AutoRig::Options::Options() = default; + +std::vector AutoRig::templateJoints(Template tmpl) +{ + switch (tmpl) { + case Template::Humanoid: return toJoints(kHumanoid, std::size(kHumanoid)); + case Template::Biped: return toJoints(kBiped, std::size(kBiped)); + case Template::Quadruped: return toJoints(kQuadruped, std::size(kQuadruped)); + case Template::Generic: return toJoints(kGeneric, std::size(kGeneric)); + } + return toJoints(kGeneric, std::size(kGeneric)); +} + +std::vector AutoRig::fitTemplate(const std::vector& tmpl, + const float* verts, + int vertexCount, + const Options& opts, + int* outRecentered) +{ + std::vector placed = tmpl; + if (outRecentered) *outRecentered = 0; + if (!verts || vertexCount <= 0 || tmpl.empty()) return placed; + + // 1. AABB of the vertex cloud. + double mn[3] = { 1e300, 1e300, 1e300}; + double mx[3] = {-1e300, -1e300, -1e300}; + for (int i = 0; i < vertexCount; ++i) { + for (int a = 0; a < 3; ++a) { + const double v = verts[3 * i + a]; + mn[a] = std::min(mn[a], v); + mx[a] = std::max(mx[a], v); + } + } + double ext[3]; + for (int a = 0; a < 3; ++a) ext[a] = std::max(1e-9, mx[a] - mn[a]); + + const int up = std::clamp(opts.upAxis, 0, 2); + // The two in-plane axes (everything that isn't "up"). + const int p0 = (up == 0) ? 1 : 0; + const int p1 = (up == 2) ? 1 : 2; + + // The template's y coordinate is "up"; its x,z are the in-plane axes. + // Map template axis -> world axis so the box orients to the mesh's up. + auto tmplAxisToWorld = [&](int tAxis) { + // tAxis: 0=template-x, 1=template-y(up), 2=template-z + if (tAxis == 1) return up; + return (tAxis == 0) ? p0 : p1; + }; + + // 2. Map each joint's normalised position into the AABB. + for (auto& j : placed) { + std::array world = {0, 0, 0}; + for (int tAxis = 0; tAxis < 3; ++tAxis) { + const int w = tmplAxisToWorld(tAxis); + world[w] = mn[w] + j.pos[tAxis] * ext[w]; + } + j.pos = world; + } + + // 3. Recentre flagged joints toward the mesh's in-plane mass at their + // up-height (pulls the spine onto the medial line, lands limb roots + // inside the silhouette). + const double slab = std::clamp(opts.slabFraction, 1e-3, 0.5) * ext[up]; + int recentered = 0; + for (auto& j : placed) { + if (!j.recenter) continue; + const double y = j.pos[up]; + double sum0 = 0, sum1 = 0; + long long n = 0; + for (int i = 0; i < vertexCount; ++i) { + if (std::abs(static_cast(verts[3 * i + up]) - y) > slab) continue; + sum0 += verts[3 * i + p0]; + sum1 += verts[3 * i + p1]; + ++n; + } + if (n > 0) { + // Blend toward the slab centroid (0.75) but keep a little of the + // template's lateral intent so symmetric joints don't all collapse + // onto the exact centre line. + const double c0 = sum0 / static_cast(n); + const double c1 = sum1 / static_cast(n); + const double kBlend = 0.75; + j.pos[p0] = kBlend * c0 + (1.0 - kBlend) * j.pos[p0]; + j.pos[p1] = kBlend * c1 + (1.0 - kBlend) * j.pos[p1]; + ++recentered; + } + } + if (outRecentered) *outRecentered = recentered; + return placed; +} + +namespace { + +// Tightly read POSITION floats out of a VertexData (same idiom as +// SkinWeights::extractPositions). Appends to `out`. +bool appendPositions(Ogre::VertexData* vd, std::vector& out) +{ + if (!vd) return false; + const auto* posElem = + vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!posElem) return false; + auto vbuf = vd->vertexBufferBinding->getBuffer(posElem->getSource()); + if (!vbuf || vd->vertexCount == 0) return false; + const size_t base0 = out.size(); + out.resize(base0 + static_cast(vd->vertexCount) * 3); + const size_t stride = vbuf->getVertexSize(); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t i = 0; i < vd->vertexCount; ++i) { + float* p = nullptr; + posElem->baseVertexPointerToElement(base + i * stride, &p); + out[base0 + 3 * i + 0] = p[0]; + out[base0 + 3 * i + 1] = p[1]; + out[base0 + 3 * i + 2] = p[2]; + } + vbuf->unlock(); + return true; +} + +} // namespace + +AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) +{ + Report report; + report.templateName = templateToString(opts.tmpl); + + if (!entity || !entity->getMesh()) { + report.error = QStringLiteral("no mesh to rig"); + return report; + } + Ogre::MeshPtr mesh = entity->getMesh(); + report.meshName = QString::fromStdString(mesh->getName()); + + if (mesh->hasSkeleton()) { + report.error = QStringLiteral( + "mesh already has a skeleton — auto-rig only applies to unrigged " + "(static) meshes"); + return report; + } + + // Gather all vertex positions (shared + per-submesh). + std::vector verts; + if (mesh->sharedVertexData) appendPositions(mesh->sharedVertexData, verts); + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (sub && !sub->useSharedVertices && sub->vertexData) + appendPositions(sub->vertexData, verts); + } + const int vcount = static_cast(verts.size() / 3); + if (vcount == 0) { + report.error = QStringLiteral("mesh has no readable vertex positions"); + return report; + } + report.verticesSampled = vcount; + + // Fit the template. + int recentered = 0; + const std::vector tmpl = templateJoints(opts.tmpl); + const std::vector placed = + fitTemplate(tmpl, verts.data(), vcount, opts, &recentered); + report.jointsRecentered = recentered; + + // Build the Ogre skeleton. Bone POSITIONS are parent-relative in Ogre, + // so each child's setPosition is its world pos minus its parent's world + // pos. createBone(name, handle) — handle == index. + auto& skelMgr = Ogre::SkeletonManager::getSingleton(); + const std::string skelName = mesh->getName() + "_autorig"; + if (skelMgr.resourceExists(skelName)) + skelMgr.remove(skelName); + Ogre::SkeletonPtr skel; + try { + skel = skelMgr.create( + skelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + std::vector bones(placed.size(), nullptr); + for (size_t i = 0; i < placed.size(); ++i) + bones[i] = skel->createBone(placed[i].name.toStdString(), + static_cast(i)); + for (size_t i = 0; i < placed.size(); ++i) { + const Joint& j = placed[i]; + Ogre::Vector3 local( + static_cast(j.pos[0]), + static_cast(j.pos[1]), + static_cast(j.pos[2])); + if (j.parent >= 0 && static_cast(j.parent) < placed.size()) { + bones[j.parent]->addChild(bones[i]); + const Joint& pj = placed[j.parent]; + local -= Ogre::Vector3( + static_cast(pj.pos[0]), + static_cast(pj.pos[1]), + static_cast(pj.pos[2])); + } + bones[i]->setPosition(local); + bones[i]->setOrientation(Ogre::Quaternion::IDENTITY); + } + skel->setBindingPose(); + + // Bind the skeleton to the mesh, then force the entity to + // re-initialise so it acquires a SkeletonInstance. Without the + // _initialise(true), the already-created Ogre::Entity keeps + // hasSkeleton()==false and BOTH exporters (FBXExporter and the + // Assimp glTF/FBX path gate on entity->hasSkeleton()) would drop + // the new rig — the skeleton would exist on the mesh but never + // reach the wire. (Same refresh EditableMesh / EditModeController + // do after mutating an entity's mesh.) + mesh->_notifySkeleton(skel); + entity->_initialise(true); + report.skeletonName = QString::fromStdString(skelName); + report.boneCount = static_cast(placed.size()); + report.applied = true; + } catch (const Ogre::Exception& e) { + report.error = QStringLiteral("Ogre error building skeleton: %1") + .arg(QString::fromStdString(e.getFullDescription())); + if (skel && skelMgr.resourceExists(skelName)) skelMgr.remove(skelName); + report.applied = false; + } + return report; +} + +QString AutoRig::templateToString(Template t) +{ + switch (t) { + case Template::Humanoid: return QStringLiteral("humanoid"); + case Template::Biped: return QStringLiteral("biped"); + case Template::Quadruped: return QStringLiteral("quadruped"); + case Template::Generic: return QStringLiteral("generic"); + } + return QStringLiteral("generic"); +} + +AutoRig::Template AutoRig::templateFromString(const QString& s) +{ + const QString l = s.trimmed().toLower(); + if (l == "humanoid") return Template::Humanoid; + if (l == "biped") return Template::Biped; + if (l == "quadruped" || l == "quad") return Template::Quadruped; + if (l == "generic") return Template::Generic; + return Template::Humanoid; // default +} + +QJsonObject AutoRig::reportToJson(const Report& r) +{ + QJsonObject o; + o["applied"] = r.applied; + o["meshName"] = r.meshName; + o["skeletonName"] = r.skeletonName; + o["template"] = r.templateName; + o["boneCount"] = r.boneCount; + o["verticesSampled"] = r.verticesSampled; + o["jointsRecentered"] = r.jointsRecentered; + if (!r.error.isEmpty()) o["error"] = r.error; + return o; +} + +QString AutoRig::reportToText(const Report& r) +{ + if (!r.applied) + return QStringLiteral("Auto-rig failed: %1\n") + .arg(r.error.isEmpty() ? QStringLiteral("unknown error") : r.error); + return QStringLiteral( + "Auto-rigged %1 with the '%2' template.\n" + " bones: %3\n vertices sampled: %4\n joints recentered: %5\n") + .arg(r.meshName, r.templateName) + .arg(r.boneCount).arg(r.verticesSampled).arg(r.jointsRecentered); +} diff --git a/src/AutoRig.h b/src/AutoRig.h new file mode 100644 index 000000000..c892b6fc0 --- /dev/null +++ b/src/AutoRig.h @@ -0,0 +1,138 @@ +#ifndef AUTO_RIG_H +#define AUTO_RIG_H + +#include +#include +#include +#include +#include + +namespace Ogre { + class Entity; + class Mesh; + class Skeleton; +} + +// Native automatic rigging — predicts a skeleton for an unrigged mesh +// (issue #407, epic #397). +// +// The issue proposes wrapping **Pinocchio** (Baran & Popović, SIGGRAPH +// 2007). Pinocchio's *core library* is **LGPL-2.1-or-later** (only its +// demo CLI is MIT). Statically vendoring an LGPL library imposes +// relink / object-file obligations that conflict with this project's +// statically-linked, permissively-redistributed binaries (Homebrew / +// Snap / WinGet / Docker) and its permissive-license stance — the same +// reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs +// GPL TetGen) shipped native heuristics instead. Pinocchio's *algorithm* +// (embed a skeleton template into the mesh interior via a distance field) +// is published and unencumbered; only its code is LGPL, so this is a +// from-scratch native implementation of the approach with **zero new +// dependencies**. +// +// Algorithm (heuristic embedding): +// 1. Read mesh vertices → axis-aligned bounding box (AABB) + the up +// axis (default +Y). +// 2. Each skeleton template is a proportional joint graph expressed in +// a normalised unit box [0,1]^3 (origin = min corner, y = up). +// Map every joint's normalised position into the mesh AABB. +// 3. Refine: for each joint, recentre it toward the mesh's mass at +// that height by snapping its in-plane (non-up) coordinates to the +// centroid of the vertices in a thin slab around the joint's up +// coordinate. This pulls the spine onto the body's medial line and +// lands limb roots inside the silhouette instead of on the AABB +// shell. Joints whose slab is empty keep their AABB-proportional +// position. +// +// The result is an Ogre::Skeleton in bind pose, ready to bind to the +// mesh and (optionally) feed into #402 SkinWeights for a one-click +// rig + skin. **Quality limits** (documented per the issue): like +// Pinocchio, this works best on roughly upright, single-component, +// manifold, T/A-pose meshes whose up axis is +Y. It is a heuristic — it +// does not detect limbs from topology, so exotic proportions or non- +// upright poses can misplace joints. + +class AutoRig { +public: + // Built-in skeleton templates. + enum class Template { + Humanoid, // pelvis/spine/head + 2 arms + 2 legs (≈ Mixamo-lite) + Biped, // simplified humanoid: spine + 2 legs + stub arms + Quadruped, // spine + 4 legs + head + tail + Generic // a simple 3-joint spine chain (fallback for anything) + }; + + // One joint of a template / placed skeleton. + struct Joint { + QString name; + int parent = -1; // index into the joint list (-1 = root) + // For a TEMPLATE: normalised position in the unit box [0,1]^3. + // For a PLACED skeleton: world-space position in mesh local space. + std::array pos = {0, 0, 0}; + // When true, the refinement step recentres this joint's in-plane + // coords toward the mesh slab centroid (spine/limb-root joints). + // When false, the joint keeps its proportional position (e.g. the + // tip of a limb, which should reach toward the AABB edge). + bool recenter = true; + }; + + struct Options { + // NOTE: declared (not defined) here so `Options{}` default args on the + // member functions below don't force aggregate init of this nested + // struct while the enclosing AutoRig class is still incomplete (which + // GCC rejects: "default member initializer for 'tmpl' needed ..."). + Options(); + Template tmpl = Template::Humanoid; + // Up axis: 0=X, 1=Y, 2=Z. Default +Y (the in-app / glTF / FBX + // convention after import normalisation). + int upAxis = 1; + // Slab half-thickness for the centroid recentre, as a fraction of + // the mesh extent along the up axis. Larger = smoother spine, + // less responsive to local mass. Range (0, 0.5]; default 0.06. + double slabFraction = 0.06; + }; + + struct Report { + QString meshName; + QString skeletonName; + QString templateName; + int boneCount = 0; + int verticesSampled = 0; + int jointsRecentered = 0; + bool applied = false; + QString error; + }; + + // --- Ogre-facing entry point (CLI / MCP / GUI) ----------------------- + + // Generate a skeleton from `opts.tmpl`, fit it to `entity`'s mesh, + // bind it (mesh->_notifySkeleton + setBindingPose), and return a + // report. The entity must be a static (skeleton-less) mesh — an + // already-rigged mesh returns applied=false with an error (unless + // it has no usable geometry). After this returns applied=true, the + // caller may chain SkinWeights::computeAndApply(entity) for weights. + static Report rigEntity(Ogre::Entity* entity, const Options& opts = {}); + + // --- Pure-data core (unit-testable, no Ogre) ------------------------- + + // The proportional joint graph for a template (positions in [0,1]^3). + static std::vector templateJoints(Template tmpl); + + // Fit `templateJoints` to a vertex cloud: map into the AABB, then + // recentre toward per-slab centroids. `vertexPositions` is tightly + // packed xyz (3 floats per vertex). Returns placed joints in the + // same order/parenting as the template, positions now in mesh local + // space. `outRecentered` (optional) receives the count of joints + // that were recentred against a non-empty slab. + static std::vector fitTemplate(const std::vector& tmpl, + const float* vertexPositions, + int vertexCount, + const Options& opts, + int* outRecentered = nullptr); + + static QString templateToString(Template t); + static Template templateFromString(const QString& s); + static QJsonObject reportToJson(const Report& r); + static QString reportToText(const Report& r); +}; + +#endif // AUTO_RIG_H diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp new file mode 100644 index 000000000..739d90d29 --- /dev/null +++ b/src/AutoRigController.cpp @@ -0,0 +1,131 @@ +#include "AutoRigController.h" +#include "AutoRig.h" +#include "SkinWeights.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include + +AutoRigController* AutoRigController::m_pSingleton = nullptr; + +AutoRigController* AutoRigController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AutoRigController(); + return m_pSingleton; +} + +AutoRigController* AutoRigController::qmlInstance(QQmlEngine* engine, QJSEngine*) +{ + Q_UNUSED(engine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AutoRigController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +AutoRigController::AutoRigController() : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &AutoRigController::selectionChanged); +} + +bool AutoRigController::hasRiggableSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + const auto entities = sel->getResolvedEntities(); + if (entities.isEmpty()) return false; + Ogre::Entity* first = entities.first(); + if (!first || !first->getMesh()) return false; + // Riggable == static (no skeleton yet). An already-skinned mesh is + // intentionally excluded (re-rigging would wipe its existing rig). + return first->getMesh()->getSkeleton() == nullptr; +} + +QVariantMap AutoRigController::autoRigSelected(const QString& templateName, + bool alsoSkin) +{ + QVariantMap result; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Auto-rig requested (%1%2)") + .arg(templateName, alsoSkin ? QStringLiteral(", +skin") : QString())); + + auto* sel = SelectionSet::getSingleton(); + const auto entities = sel ? sel->getResolvedEntities() : QList{}; + if (entities.isEmpty()) { + const auto msg = QStringLiteral("No mesh selected."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + Ogre::Entity* entity = entities.first(); + if (!entity || !entity->getMesh()) { + const auto msg = QStringLiteral("Selected entity is no longer valid."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + + AutoRig::Options opts; + opts.tmpl = AutoRig::templateFromString(templateName); + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("UI auto-rig entity=%1 template=%2") + .arg(QString::fromStdString(entity->getName()), + AutoRig::templateToString(opts.tmpl))); + + m_busy = true; + emit busyChanged(); + + AutoRig::Report report; + bool skinned = false; + try { + report = AutoRig::rigEntity(entity, opts); + if (report.applied && alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) + report.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } + } catch (const Ogre::Exception& e) { + m_busy = false; + emit busyChanged(); + const auto msg = QString::fromStdString(e.getFullDescription()); + emit error(QStringLiteral("Ogre error: %1").arg(msg)); + result["applied"] = false; + result["error"] = msg; + return result; + } + + m_busy = false; + emit busyChanged(); + emit selectionChanged(); // skeleton state changed → refresh button bindings + + result["applied"] = report.applied; + result["meshName"] = report.meshName; + result["skeletonName"] = report.skeletonName; + result["template"] = report.templateName; + result["boneCount"] = report.boneCount; + result["verticesSampled"] = report.verticesSampled; + result["jointsRecentered"] = report.jointsRecentered; + result["skinned"] = skinned; + if (!report.error.isEmpty()) result["error"] = report.error; + + if (report.applied) emit rigged(result); + else emit error(report.error.isEmpty() + ? QStringLiteral("Auto-rig failed") : report.error); + + return result; +} diff --git a/src/AutoRigController.h b/src/AutoRigController.h new file mode 100644 index 000000000..7ddc8fee0 --- /dev/null +++ b/src/AutoRigController.h @@ -0,0 +1,54 @@ +#ifndef AUTO_RIG_CONTROLLER_H +#define AUTO_RIG_CONTROLLER_H + +#include +#include +#include + +// QML-facing singleton for native auto-rigging (issue #407). +// Wraps `AutoRig::rigEntity` (+ optional `SkinWeights::computeAndApply`) +// and exposes selection state so the Animation-Mode button can disable +// itself when the selection isn't a riggable static mesh. +class AutoRigController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + // True when the selected entity is a STATIC (skeleton-less) mesh — + // the only thing auto-rig can sensibly act on. Already-rigged meshes + // and empty selections disable the button. + Q_PROPERTY(bool hasRiggableSelection READ hasRiggableSelection NOTIFY selectionChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + +public: + static AutoRigController* instance(); + static AutoRigController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + bool hasRiggableSelection() const; + bool busy() const { return m_busy; } + + /// Auto-rig the first resolved selected entity with `templateName` + /// (humanoid / biped / quadruped / generic). When `alsoSkin` is true, + /// chains SkinWeights::computeAndApply so the mesh deforms immediately. + /// Returns a QVariantMap mirroring AutoRig::Report (+ a `skinned` bool). + /// Emits `rigged(report)` on success or `error(msg)` on failure. + Q_INVOKABLE QVariantMap autoRigSelected(const QString& templateName, + bool alsoSkin); + +signals: + void selectionChanged(); + void busyChanged(); + void rigged(const QVariantMap& report); + void error(const QString& message); + +private: + AutoRigController(); + ~AutoRigController() override = default; + + static AutoRigController* m_pSingleton; + bool m_busy = false; +}; + +#endif // AUTO_RIG_CONTROLLER_H diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp new file mode 100644 index 000000000..5edfa3957 --- /dev/null +++ b/src/AutoRig_test.cpp @@ -0,0 +1,157 @@ +// Unit tests for AutoRig (#407). The pure-data core (templateJoints / +// fitTemplate) needs no Ogre/GL context, so these run everywhere — unlike +// rigEntity() which needs a loaded mesh (covered by the CLI coverage test +// under Xvfb on CI). + +#include + +#include +#include + +#include "AutoRig.h" + +namespace { + +// Build a synthetic upright "humanoid-ish" point cloud: 2 units tall (y), +// ~1 wide at the shoulders, narrow elsewhere, centred on x/z=0. +std::vector uprightCloud() +{ + std::vector v; + for (int i = 0; i < 2000; ++i) { + const float y = (static_cast(i) / 2000.0f) * 2.0f; + const float w = (y > 1.4f && y < 1.7f) ? 0.9f : 0.35f; // shoulders bulge + for (int s = -1; s <= 1; s += 2) { + v.push_back(s * w * 0.5f); + v.push_back(y); + v.push_back(0.0f); + } + } + return v; +} + +} // namespace + +TEST(AutoRigCore, TemplatesAreNonEmptyAndWellParented) +{ + for (auto t : {AutoRig::Template::Humanoid, AutoRig::Template::Biped, + AutoRig::Template::Quadruped, AutoRig::Template::Generic}) { + const auto js = AutoRig::templateJoints(t); + ASSERT_FALSE(js.empty()); + // Exactly one root; every non-root parent index is a valid earlier joint. + int roots = 0; + for (size_t i = 0; i < js.size(); ++i) { + if (js[i].parent < 0) { ++roots; continue; } + EXPECT_GE(js[i].parent, 0); + EXPECT_LT(static_cast(js[i].parent), js.size()); + EXPECT_LT(static_cast(js[i].parent), i) + << "parent must precede child for single-pass bone build"; + // Normalised template coords stay in [0,1]. + for (int a = 0; a < 3; ++a) { + EXPECT_GE(js[i].pos[a], 0.0); + EXPECT_LE(js[i].pos[a], 1.0); + } + } + EXPECT_EQ(roots, 1) << "template must have exactly one root"; + } +} + +TEST(AutoRigCore, HumanoidHasExpectedBoneCount) +{ + EXPECT_EQ(AutoRig::templateJoints(AutoRig::Template::Humanoid).size(), 19u); + EXPECT_EQ(AutoRig::templateJoints(AutoRig::Template::Generic).size(), 3u); +} + +TEST(AutoRigCore, FitPlacesAllJointsInsideAABB) +{ + const auto cloud = uprightCloud(); + const int n = static_cast(cloud.size() / 3); + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + + AutoRig::Options o; + o.tmpl = AutoRig::Template::Humanoid; + o.upAxis = 1; + int recentered = 0; + const auto placed = AutoRig::fitTemplate(tmpl, cloud.data(), n, o, &recentered); + + ASSERT_EQ(placed.size(), tmpl.size()); + EXPECT_GT(recentered, 0) << "spine/limb-root joints should recentre on a real cloud"; + + // Cloud AABB: x in [-0.45, 0.45], y in [0, 2], z == 0. + for (const auto& j : placed) { + EXPECT_GE(j.pos[1], -1e-3); + EXPECT_LE(j.pos[1], 2.0 + 1e-3) << j.name.toStdString() << " y out of AABB"; + EXPECT_GE(j.pos[0], -0.45 - 1e-3); + EXPECT_LE(j.pos[0], 0.45 + 1e-3) << j.name.toStdString() << " x out of AABB"; + } +} + +TEST(AutoRigCore, FitRespectsVerticalOrdering) +{ + const auto cloud = uprightCloud(); + const int n = static_cast(cloud.size() / 3); + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options o; + const auto placed = AutoRig::fitTemplate(tmpl, cloud.data(), n, o, nullptr); + + auto yOf = [&](const QString& name) -> double { + for (const auto& j : placed) if (j.name == name) return j.pos[1]; + return -1e9; + }; + // Head above hips above feet. + EXPECT_GT(yOf("Head"), yOf("Hips")); + EXPECT_GT(yOf("Hips"), yOf("LeftFoot")); + EXPECT_GT(yOf("Hips"), yOf("RightFoot")); + // Symmetric feet stay on opposite sides of centre (x sign preserved). + EXPECT_GT(yOf("Head"), 1.5); // head lands in the upper portion +} + +TEST(AutoRigCore, FitIsRobustToDegenerateInput) +{ + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Generic); + AutoRig::Options o; + int rc = -1; + // Null / zero-count → returns the template unchanged, no crash, rc=0. + const auto p0 = AutoRig::fitTemplate(tmpl, nullptr, 0, o, &rc); + EXPECT_EQ(p0.size(), tmpl.size()); + EXPECT_EQ(rc, 0); + + // Single degenerate vertex (all same point) → no division blow-up. + std::vector one = {0.5f, 0.5f, 0.5f}; + const auto p1 = AutoRig::fitTemplate(tmpl, one.data(), 1, o, &rc); + EXPECT_EQ(p1.size(), tmpl.size()); + for (const auto& j : p1) + for (int a = 0; a < 3; ++a) + EXPECT_TRUE(std::isfinite(j.pos[a])); +} + +TEST(AutoRigCore, TemplateStringRoundTrip) +{ + using T = AutoRig::Template; + for (auto t : {T::Humanoid, T::Biped, T::Quadruped, T::Generic}) + EXPECT_EQ(AutoRig::templateFromString(AutoRig::templateToString(t)), t); + // Unknown → humanoid default; alias "quad". + EXPECT_EQ(AutoRig::templateFromString("nonsense"), T::Humanoid); + EXPECT_EQ(AutoRig::templateFromString("quad"), T::Quadruped); + EXPECT_EQ(AutoRig::templateFromString("HUMANOID"), T::Humanoid); +} + +TEST(AutoRigCore, ReportSerialization) +{ + AutoRig::Report r; + r.applied = true; + r.meshName = "robot"; + r.templateName = "humanoid"; + r.boneCount = 19; + r.verticesSampled = 1234; + r.jointsRecentered = 11; + const auto j = AutoRig::reportToJson(r); + EXPECT_TRUE(j["applied"].toBool()); + EXPECT_EQ(j["boneCount"].toInt(), 19); + EXPECT_EQ(j["template"].toString(), "humanoid"); + EXPECT_FALSE(AutoRig::reportToText(r).isEmpty()); + + AutoRig::Report fail; + fail.applied = false; + fail.error = "boom"; + EXPECT_TRUE(AutoRig::reportToText(fail).contains("boom")); +} diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index d011779a5..ca64afc90 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -23,6 +23,7 @@ #include "UvUnwrap.h" #include "QuadRetopo.h" #include "SkinWeights.h" +#include "AutoRig.h" #include "MeshDecimator.h" #include "EditableMesh.h" #include "TexturePaintBuffer.h" @@ -1497,6 +1498,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "uv") rc = cmdUv(argc, argv); else if (cmd == "retopo") rc = cmdRetopo(argc, argv); else if (cmd == "skin") rc = cmdSkin(argc, argv); + else if (cmd == "rig") rc = cmdRig(argc, argv); else if (cmd == "morph") rc = cmdMorph(argc, argv); else if (cmd == "nodeanim") rc = cmdNodeAnim(argc, argv); else if (cmd == "cloud") rc = CloudCLIPipeline::run(argc, argv); @@ -8162,6 +8164,123 @@ int CLIPipeline::cmdSkin(int argc, char* argv[]) return 0; } +int CLIPipeline::cmdRig(int argc, char* argv[]) +{ + // Parse: rig [--skeleton humanoid|biped|quadruped|generic] + // [--skin] [--up-axis x|y|z] -o [--json] + QString inputPath, outputPath, templateName = QStringLiteral("humanoid"); + bool jsonOutput = false; + bool alsoSkin = false; + int upAxis = 1; // +Y default + + for (int i = 1; i < argc; ++i) { + const QString arg = QString::fromLocal8Bit(argv[i]); + if (arg == "rig" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--skin") { alsoSkin = true; continue; } + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString::fromLocal8Bit(argv[++i]); continue; + } + if ((arg == "--skeleton" || arg == "--template") && i + 1 < argc) { + templateName = QString::fromLocal8Bit(argv[++i]); continue; + } + if (arg == "--up-axis" && i + 1 < argc) { + const QString a = QString::fromLocal8Bit(argv[++i]).toLower(); + if (a == "x") upAxis = 0; + else if (a == "y") upAxis = 1; + else if (a == "z") upAxis = 2; + else { err() << "Error: --up-axis must be x, y, or z." << Qt::endl; return 2; } + continue; + } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh rig [--skeleton humanoid|biped|quadruped|generic] " + "[--skin] [--up-axis x|y|z] -o [--json]" << Qt::endl; + return 2; + } + if (outputPath.isEmpty()) { + err() << "Error: -o required." << Qt::endl; + return 2; + } + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: file not found: " << inputPath << Qt::endl; return 1; + } + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QString("rig .%1 template=%2 skin=%3") + .arg(fi.suffix(), templateName).arg(alsoSkin)); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + QList meshEntities; + for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) { + if (e && e->getMovableType() == "Entity") + meshEntities.push_back(e); + } + if (meshEntities.isEmpty()) { + err() << "Error: failed to load " << inputPath << Qt::endl; return 1; + } + if (meshEntities.size() > 1) { + err() << "Error: " << inputPath + << " contains multiple mesh entities. `qtmesh rig` supports one " + "entity per file." << Qt::endl; + return 1; + } + Ogre::Entity* entity = meshEntities.first(); + + AutoRig::Options opts; + opts.tmpl = AutoRig::templateFromString(templateName); + opts.upAxis = upAxis; + + AutoRig::Report report = AutoRig::rigEntity(entity, opts); + if (!report.applied) { + err() << "Error: auto-rig failed — " << report.error << Qt::endl; + return 1; + } + + // Optionally chain skin weights so the exported asset deforms. + bool skinned = false; + if (alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) { + err() << "Error: rigged, but skinning failed — " << sw.error << Qt::endl; + return 1; + } + } + + auto* node = entity->getParentSceneNode(); + const QString fmt = formatForExtension(outputPath); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QString("Exporting %1").arg(QFileInfo(outputPath).absoluteFilePath())); + if (MeshImporterExporter::exporter(node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { + err() << "Error: export failed." << Qt::endl; + return 1; + } + + if (jsonOutput) { + QJsonObject j = AutoRig::reportToJson(report); + j["skinned"] = skinned; + cliWrite(QString::fromUtf8( + QJsonDocument(j).toJson(QJsonDocument::Indented)) + "\n"); + } else { + cliWrite(AutoRig::reportToText(report) + + (alsoSkin ? QString(" skinned: %1\n").arg(skinned ? "yes" : "no") + : QString()) + + QString("Wrote: %1\n").arg(QFileInfo(outputPath).fileName())); + } + return 0; +} + int CLIPipeline::cmdMorph(int argc, char* argv[]) { // Parse: morph --list [--json] diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 4a01e287a..7a2eee40c 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -207,6 +207,11 @@ class CLIPipeline { /// distance heuristic. Issue #402. static int cmdSkin(int argc, char* argv[]); + /// Native auto-rig: embed a skeleton template (humanoid / biped / + /// quadruped / generic) into an unrigged mesh, optionally chain + /// skin weights (--skin), and export. Issue #407. + static int cmdRig(int argc, char* argv[]); + /// List the morph targets / blend shapes on a mesh file. Slice A1 /// surfaces a `--list` mode only; subsequent slices add `--set`, /// `--add`, `--delete` once the in-memory authoring path lands. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3d5646a0a..835eea086 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -90,6 +90,8 @@ QuadRetopo.cpp QuadRetopoController.cpp SkinWeights.cpp SkinWeightsController.cpp +AutoRig.cpp +AutoRigController.cpp MeshDepthRenderer.cpp MultiViewTextureBaker.cpp TextureChannelPacker.cpp @@ -229,6 +231,8 @@ QuadRetopo.h QuadRetopoController.h SkinWeights.h SkinWeightsController.h +AutoRig.h +AutoRigController.h MeshDepthRenderer.h MultiViewTextureBaker.h TextureChannelPacker.h From db6937ebe18444d842a5d52b7b4be4eee3bd71b7 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 11:49:47 -0400 Subject: [PATCH 02/24] feat(#407): MCP auto_rig tool + GUI Auto-Rig + rig CLI tests + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP: `auto_rig` { template, skin?, up_axis?, output_path? } (MCPServer::toolAutoRig) — rigs the selected static mesh, optional skin chain + optional re-export. Registered + advertised. Breadcrumb ai.assist.auto_rig. - GUI: AutoRigDialog.qml (template + up-axis pickers, "also skin" checkbox) driven by AutoRigController; new "Rigging" CollapsibleSection in Animation Mode → Mode Tools, gated on AutoRigController.hasRiggableSelection (a static mesh — already-rigged meshes show "Skinning" instead). Lazy-loaded Loader + openAutoRigDialog(), registered in qml_resources.qrc. - Tests: CLIPipeline_cmdrig_coverage_test.cpp (arg-validation + file-missing branches need no GL; success path skips gracefully without Xvfb). - CLAUDE.md: CLI examples (skin + rig), recognized-subcommand list, and a full AutoRig architecture entry (incl. the LGPL→native rationale, the _initialise(true) export gotcha, and documented quality limits). App + UnitTests build clean on macOS arm64. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +- qml/AutoRigDialog.qml | 287 +++++++++++++++++++++++ qml/PropertiesPanel.qml | 101 ++++++++ src/CLIPipeline_cmdrig_coverage_test.cpp | 138 +++++++++++ src/MCPServer.cpp | 115 +++++++++ src/MCPServer.h | 3 + src/qml_resources.qrc | 1 + 7 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 qml/AutoRigDialog.qml create mode 100644 src/CLIPipeline_cmdrig_coverage_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 61478f3ce..1e16fb088 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,9 +106,12 @@ qtmesh uv model.fbx --info # report current UV channels + UV qtmesh uv model.fbx --info --json # same, as JSON qtmesh uv model.fbx --unwrap -o unwrapped.glb # xatlas auto-UV unwrap (#400). Non-overlapping UVs into UV0. qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb # write into UV1 (lightmap workflow) +qtmesh skin model.fbx --max-influences 4 --falloff 4 -o skinned.fbx # auto skin weights (inverse-distance) for a mesh+skeleton (#402) +qtmesh rig model.obj --skeleton humanoid -o rigged.fbx # native auto-rig: embed a skeleton template into an unrigged mesh (#407) +qtmesh rig model.obj --skeleton humanoid --skin -o rigged.fbx # one-click rig + skin (chains #402); templates: humanoid|biped|quadruped|generic; --up-axis x|y|z (default y) ``` -CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. +CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`, `rig`) 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. @@ -255,6 +258,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Real-ESRGAN texture upscaling** (`src/TextureUpscaler.h/cpp` + `AIAssistManager`, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. `TextureUpscaler` is the Ogre-free core (reuses `PbrMapSynth::toNCHW`/`nchwToRgb`): a **scale-aware** overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). `AIAssistManager::upscaleTexture(srcPath, scale, overwrite)` extends the per-model `Map` enum with `UpscaleX2`/`UpscaleX4`, downloads the model on first use (same HF repo), runs, caches `_upscaled_x{2,4}.png` next to the source, and emits `upscaleStarted/Completed/Error`. The Material Editor path is worker-threaded and reports state via `upscaleDownloading` (first-run model fetch) / `upscaleProgress(done,total)` (per tile) / `upscaleCompleted`/`upscaleError`; `cancelUpscale()` flips a shared atomic that the tiling loop's `ProgressFn` checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. **Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, [xinntao](https://github.com/xinntao/Real-ESRGAN))** — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via `scripts/export-realesrgan-onnx.py` (one-time, offline, NOT shipped). Surfaced via **CLI `qtmesh material --texture --upscale {2|4} [-o ]`** (`CLIPipeline::cmdMaterialUpscale`), the MCP `upscale_texture` tool, and **"Upscale 2× / 4×" buttons** in the Material Editor's Texture Properties panel. Sentry breadcrumb category `ai.assist.upscale`. ONNX intra-op threads are set to `hardware_concurrency-1` (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — `PbrMapSynth` stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded. - **LLM-assisted material from a description** (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → `MaterialEditorQML::generateMaterialFromPrompt` → `LLMManager::generateMaterial`); #406 adds the missing **CLI + MCP parity** by reusing that exact path headlessly. The shared core `CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)` resolves a GGUF model (the `--model`/`model` override, else last-used / first available via `LLMManager::scanForModels`+`availableModels`), drives `LLMManager::generateMaterial` synchronously through two `QEventLoop`s (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the `material ` header, parses the script via `MaterialManager::parseScript`, `compile()`s, honors a `pbr_workflow` tag through `RTShaderHelper::applyPbrIfTagged`, and binds the material to every submesh of the entity. The **CLI** `qtmesh material --describe "" [--model ] [-o out]` (`CLIPipeline::cmdMaterialDescribe`) imports → applies → re-exports; the **MCP** `describe_material` tool (`MCPServer::toolDescribeMaterial`, args `{prompt, mesh?, model?, output_path?}`) applies to the named/selected entity in-session and optionally re-exports when `output_path` is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — `LLMManager.cpp` always compiles, so no `#ifdef ENABLE_LOCAL_LLM` guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category `ai.assist.describe_material`. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer. - **SkinWeights** (`src/SkinWeights.h/cpp`, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights. The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is **GPL/copyleft**. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with **zero new dependencies**: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply `1/dist^falloff` weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (`maxInfluenceDistance` × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional `skipUnweightedBones` filters Mixamo helper bones. `replaceExisting=false` enables a merge mode for "fill in missing weights" workflows. Surfaced via `qtmesh skin --max-influences N --falloff F -o out`, MCP `compute_skin_weights`, and the **Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button** (`qml/SkinWeightsDialog.qml`, driven by `SkinWeightsController` singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to `hasSkinnedSelection` so it disables on static (skeleton-less) meshes. The GUI path runs through `ComputeSkinWeightsCommand` (`src/commands/`) so the auto-skin is **undoable** (Ctrl+Z): the command snapshots every submesh's `VertexBoneAssignmentList` (+ the mesh-level shared list) before the first `redo`, runs `computeAndApply`, and on `undo` restores the snapshot and calls `_compileBoneAssignments` to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category `ai.assist.skin_weights`. A future slice can plug libigl BBW in behind `-DENABLE_LIBIGL_BBW` for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip. +- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. - **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/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml new file mode 100644 index 000000000..e3843a6e5 --- /dev/null +++ b/qml/AutoRigDialog.qml @@ -0,0 +1,287 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import MaterialEditorQML 1.0 +import PropertiesPanel 1.0 + +// Issue #407: top-level Window for native auto-rigging. Same Inspector-styled +// idiom as SkinWeightsDialog / QuadRetopoDialog. Operates on the currently +// selected STATIC entity — the button disables on already-rigged or empty +// selections (AutoRigController.hasRiggableSelection). +Window { + id: dialog + title: "Auto-Rig" + width: 560 + height: 420 + minimumWidth: 480 + minimumHeight: 380 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property var templates: ["humanoid", "biped", "quadruped", "generic"] + property int templateIndex: 0 + property var upAxes: ["x", "y", "z"] + property int upAxisIndex: 1 // +Y default + property bool alsoSkin: true + + property string lastStatus: "" + property bool lastWasError: false + + function open() { + dialog.lastStatus = "" + dialog.lastWasError = false + dialog.show() + dialog.raise() + dialog.requestActivate() + keyCapture.forceActiveFocus() + } + + function runRig() { + if (AutoRigController.busy) return + if (!AutoRigController.hasRiggableSelection) return + const r = AutoRigController.autoRigSelected( + dialog.templates[dialog.templateIndex], + dialog.alsoSkin) + if (r && r.applied) { + dialog.lastStatus = + "Rigged: " + r.boneCount + " bones, " + + r.verticesSampled + " verts sampled, " + + r.jointsRecentered + " joints recentered" + + (dialog.alsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + 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.runRig() + event.accepted = true + } + } + } + + // ── Inline Inspector primitives (match SkinWeightsDialog) ─────────── + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + 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 + 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 InspectorCheckbox: Rectangle { + id: cb + property string label: "" + property bool checked: false + signal toggled() + 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 + 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() + } + } + + // A minimal segmented picker (no ComboBox dependency, matches the + // hand-rolled Inspector style). + component InspectorSegments: Row { + id: seg + property var options: [] + property int index: 0 + signal picked(int i) + spacing: 4 + Repeater { + model: seg.options + Rectangle { + width: Math.max(60, segText.implicitWidth + 18) + height: 24 + radius: 3 + color: index === seg.index + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: segText + anchors.centerIn: parent + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: seg.picked(index) + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.85 + text: "Embed a skeleton template into the selected unrigged mesh. " + + "Native heuristic (no external deps): maps a proportional joint " + + "graph into the mesh bounds and recentres joints toward the " + + "mesh's medial mass. Works best on roughly upright, manifold, " + + "T/A-pose meshes with +Y up. Already-rigged meshes are not " + + "eligible." + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Skeleton:"; Layout.preferredWidth: 80 } + InspectorSegments { + options: dialog.templates + index: dialog.templateIndex + onPicked: function(i) { dialog.templateIndex = i } + } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Up axis:"; Layout.preferredWidth: 80 } + InspectorSegments { + options: dialog.upAxes + index: dialog.upAxisIndex + onPicked: function(i) { dialog.upAxisIndex = i } + } + InspectorLabel { + text: "(+Y is the in-app default after import)" + opacity: 0.7 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: ""; Layout.preferredWidth: 80 } + InspectorCheckbox { + Layout.fillWidth: true + label: "Also compute skin weights (one-click rig + skin)" + checked: dialog.alsoSkin + onToggled: dialog.alsoSkin = !dialog.alsoSkin + } + } + + Item { Layout.fillHeight: true } + + InspectorLabel { + Layout.fillWidth: true + visible: dialog.lastStatus.length > 0 + text: dialog.lastStatus + wrapMode: Text.WordWrap + color: dialog.lastWasError ? "#cc4444" : "#3a8c3a" + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + InspectorButton { + label: "Close" + Layout.preferredWidth: 90 + onClicked: dialog.close() + } + InspectorButton { + label: AutoRigController.busy ? "Rigging…" : "Auto-Rig" + Layout.preferredWidth: 160 + buttonEnabled: !AutoRigController.busy + && AutoRigController.hasRiggableSelection + onClicked: dialog.runRig() + } + } + } + + Connections { + target: AutoRigController + function onError(msg) { + dialog.lastStatus = "Failed: " + msg + dialog.lastWasError = true + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 1b94886c1..2f4f4af2f 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -331,6 +331,23 @@ Rectangle { Component.onCompleted: content = skinningToolsComponent } + // ---- Rigging (Animation mode) ---- + // Issue #407: native auto-rig. Shown in Animation Mode for a + // STATIC (skeleton-less) selection — embedding a skeleton is the + // step that turns a static mesh into an animatable one, so it + // belongs next to Skinning. Gated on hasRiggableSelection (a + // static mesh); already-rigged meshes show the Skinning section + // instead. + CollapsibleSection { + title: "Rigging" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && AutoRigController.hasRiggableSelection + expanded: false + + Component.onCompleted: content = riggingToolsComponent + } + // ---- Texture Paint (Material mode) ---- // (Brush color/radius/strength/falloff live on the toolbar // paint-brush popup. The Inspector panel keeps only the @@ -1267,6 +1284,74 @@ Rectangle { } } + // ---- Rigging Tools Content (Animation mode) ---- + // Issue #407: native auto-rig. The "Auto-Rig…" button opens the dialog + // (template picker + skin checkbox); it disables on non-static meshes + // (AutoRigController.hasRiggableSelection). + Component { + id: riggingToolsComponent + + 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: "Embed a skeleton template (humanoid / biped / quadruped / " + + "generic) into the selected unrigged mesh, optionally skinning " + + "it in one click. Best on upright, manifold, T/A-pose meshes." + } + + Rectangle { + id: rigBtn + width: Math.min(parent.width - 16, rigLabel.implicitWidth + 16) + height: 26 + radius: 3 + opacity: AutoRigController.hasRiggableSelection ? 1.0 : 0.45 + color: rigMa.containsMouse && AutoRigController.hasRiggableSelection + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + activeFocusOnTab: AutoRigController.hasRiggableSelection + Accessible.role: Accessible.Button + Accessible.name: "Auto-Rig" + Keys.onSpacePressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + Keys.onReturnPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + Keys.onEnterPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + border.color: rigBtn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: rigBtn.activeFocus ? 2 : 1 + + Text { + id: rigLabel + anchors.centerIn: parent + text: "Auto-Rig…" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: rigMa + anchors.fill: parent + hoverEnabled: true + enabled: AutoRigController.hasRiggableSelection + cursorShape: AutoRigController.hasRiggableSelection + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: root.openAutoRigDialog() + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: AutoRigController.hasRiggableSelection + ? "Generate a skeleton for this static mesh by embedding a template." + : "Select a static (unrigged) mesh first." + } + } + } + } + // ---- Edit Mode Tools Content ---- Component { id: editModeToolsComponent @@ -4104,6 +4189,22 @@ Rectangle { } } + // Issue #407: native auto-rig dialog. Same lazy-load idiom. + Loader { + id: autoRigLoader + active: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/AutoRigDialog.qml" + onLoaded: if (item && item.open) item.open() + } + function openAutoRigDialog() { + if (!autoRigLoader.active) { + autoRigLoader.active = true + } else if (autoRigLoader.item) { + autoRigLoader.item.open() + } + } + Loader { id: isometricSpritesLoader active: false diff --git a/src/CLIPipeline_cmdrig_coverage_test.cpp b/src/CLIPipeline_cmdrig_coverage_test.cpp new file mode 100644 index 000000000..85344a997 --- /dev/null +++ b/src/CLIPipeline_cmdrig_coverage_test.cpp @@ -0,0 +1,138 @@ +// Coverage tests for CLIPipeline::cmdRig (#407, auto-rig). Mirrors the +// cmdSkin coverage style: the argument-validation branches (return 2) and the +// file-not-found branch (return 1) need no GL context, so they exercise the +// parser without a loaded mesh. The full rig+export path needs a real mesh and +// is exercised under Xvfb on CI via the success-path test below (which is +// skipped gracefully when Ogre can't init). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CLIPipeline.h" +#include "TestHelpers.h" + +namespace { + +// RAII argc/argv builder, own anon-namespace name (no ODR clash). +class RigArgv { +public: + RigArgv(std::initializer_list args) + { + for (auto* a : args) m_storage.push_back(QByteArray(a)); + for (auto& ba : m_storage) m_argv.push_back(ba.data()); + m_argc = static_cast(m_argv.size()); + } + int argc() const { return m_argc; } + char** argv() { return m_argv.data(); } +private: + QList m_storage; + QList m_argv; + int m_argc = 0; +}; + +const char* kMissingFile = "/nonexistent_qtmesh_rig_input_zzz.obj"; + +} // namespace + +// ── Required-argument checks (return 2) ───────────────────────────────────── + +TEST(CLIPipelineCmdRigCoverageError, NoInputFile) +{ + RigArgv args({"rig"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, NoInputButFlags) +{ + RigArgv args({"rig", "--json", "--skin"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, InputButNoOutput) +{ + RigArgv args({"rig", kMissingFile}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, BadUpAxisIsUsageError) +{ + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--up-axis", "w"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +// ── File-existence branch (return 1) ──────────────────────────────────────── + +TEST(CLIPipelineCmdRigCoverageError, MissingFileWithValidArgs) +{ + // Valid template + output, but the input doesn't exist -> 1. + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--skeleton", "humanoid"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdRigCoverageError, UnknownTemplateStillParsesThenFileMissing) +{ + // An unrecognised template name is tolerated by templateFromString + // (falls back to humanoid), so it must NOT be a usage error (2); + // it proceeds to the file-existence check -> 1. + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--skeleton", "dragon"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdRigCoverageError, EveryValidUpAxisParses) +{ + for (const char* ax : {"x", "y", "z"}) { + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--up-axis", ax}); + // Valid axis -> passes parse, then file-not-found -> 1 (never 2). + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1) + << "up-axis " << ax << " should parse"; + } +} + +// ── Success path (needs a GL/Ogre context; skipped without one) ───────────── + +TEST(CLIPipelineCmdRigSuccess, RigsStaticMeshAndExports) +{ + if (!tryInitOgre() || !canLoadMeshFiles()) + GTEST_SKIP() << "Ogre/GL unavailable (needs Xvfb)."; + + // Build a static (skeleton-less) mesh on disk by exporting a simple + // in-memory triangle mesh to OBJ — OBJ carries no skeleton. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // Reuse the editor's own loader path: write a minimal OBJ cube-ish quad. + const QString objPath = dir.filePath("static.obj"); + { + QFile f(objPath); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + // A small upright pyramid-ish shape (8 verts spanning a 1x2x1 box). + const char* obj = + "v -0.4 0 -0.4\nv 0.4 0 -0.4\nv 0.4 0 0.4\nv -0.4 0 0.4\n" + "v -0.2 2 -0.2\nv 0.2 2 -0.2\nv 0.2 2 0.2\nv -0.2 2 0.2\n" + "f 1 2 3\nf 1 3 4\nf 5 6 7\nf 5 7 8\n" + "f 1 2 6\nf 1 6 5\nf 3 4 8\nf 3 8 7\n"; + f.write(obj); + f.close(); + } + + const QString outPath = dir.filePath("rigged.gltf"); + // Hold the path bytes in stable std::strings so the argv char* stay valid. + const std::string objStr = objPath.toStdString(); + const std::string outStr = outPath.toStdString(); + RigArgv args({"rig", objStr.c_str(), "-o", outStr.c_str(), + "--skeleton", "humanoid"}); + const int rc = CLIPipeline::cmdRig(args.argc(), args.argv()); + // Either it rigs+exports (0) or the OBJ import path isn't available in this + // headless build (1) — but it must never crash or return a usage error. + EXPECT_NE(rc, 2); + if (rc == 0) + EXPECT_TRUE(QFile::exists(outPath)) << "rigged mesh should be written"; +} diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 5024c33cf..b42cb2325 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -38,6 +38,7 @@ #include "ScanEngine.h" #include "QuadRetopo.h" #include "SkinWeights.h" +#include "AutoRig.h" #include "MeshDepthRenderer.h" #include "ModelIsometricRenderer.h" #ifdef ENABLE_STABLE_DIFFUSION @@ -575,6 +576,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("auto_uv_unwrap"), &MCPServer::toolAutoUvUnwrap}, {QStringLiteral("retopologize"), &MCPServer::toolRetopologize}, {QStringLiteral("compute_skin_weights"), &MCPServer::toolComputeSkinWeights}, + {QStringLiteral("auto_rig"), &MCPServer::toolAutoRig}, {QStringLiteral("generate_mesh_texture"), &MCPServer::toolGenerateMeshTexture}, {QStringLiteral("generate_pbr_maps"), &MCPServer::toolGeneratePbrMaps}, {QStringLiteral("upscale_texture"), &MCPServer::toolUpscaleTexture}, @@ -1662,6 +1664,90 @@ QJsonObject MCPServer::toolComputeSkinWeights(const QJsonObject &args) return result; } +QJsonObject MCPServer::toolAutoRig(const QJsonObject &args) +{ + // Issue #407: native auto-rig of the selected STATIC mesh. Generates a + // skeleton from a template, binds it, optionally chains skin weights, and + // optionally re-exports. + if (!hasSelectedEntities()) + return makeErrorResult("No mesh selected. Load a mesh first with load_mesh."); + + if (args.contains("skin") && !args["skin"].isBool()) + return makeErrorResult("Error: 'skin' must be a boolean."); + + AutoRig::Options opts; + if (args.contains("template")) { + if (!args["template"].isString()) + return makeErrorResult("Error: 'template' must be a string."); + opts.tmpl = AutoRig::templateFromString(args["template"].toString()); + } + if (args.contains("up_axis")) { + const QString a = args["up_axis"].toString().toLower(); + if (a == "x") opts.upAxis = 0; + else if (a == "y") opts.upAxis = 1; + else if (a == "z") opts.upAxis = 2; + else return makeErrorResult("Error: 'up_axis' must be 'x', 'y', or 'z'."); + } + const bool alsoSkin = args.value("skin").toBool(false); + + SelectionSet* sel = SelectionSet::getSingleton(); + const QList resolved = sel ? sel->getResolvedEntities() + : QList{}; + if (resolved.isEmpty()) + return makeErrorResult("No selected entity."); + Ogre::Entity* entity = resolved.first(); + if (!entity) return makeErrorResult("Selected entity is null."); + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("auto_rig entity=%1 template=%2 skin=%3") + .arg(QString::fromStdString(entity->getName()), + AutoRig::templateToString(opts.tmpl)) + .arg(alsoSkin)); + + AutoRig::Report report; + bool skinned = false; + try { + report = AutoRig::rigEntity(entity, opts); + if (report.applied && alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) + report.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } + } catch (const Ogre::Exception& e) { + return makeErrorResult(QStringLiteral("Ogre error: %1") + .arg(QString::fromStdString(e.getFullDescription()))); + } + + if (!report.applied) + return makeErrorResult(QStringLiteral("Auto-rig failed: %1").arg(report.error)); + + // Optional re-export of the now-rigged mesh. + const QString outputPath = args["output_path"].toString(); + if (!outputPath.isEmpty()) { + Ogre::SceneNode* node = entity->getParentSceneNode(); + if (!node) + return makeErrorResult( + QStringLiteral("Error: rigged, but the entity has no scene node to " + "export from")); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("auto_rig export to %1").arg(outputPath)); + const int rc = MeshImporterExporter::exporter( + node, outputPath, CLIPipeline::formatForExtension(outputPath)); + if (rc != 0) + return makeErrorResult( + QStringLiteral("Error: rigged but export to '%1' failed (code %2)") + .arg(outputPath).arg(rc)); + } + + QJsonObject result = makeSuccessResult(AutoRig::reportToText(report)); + QJsonObject j = AutoRig::reportToJson(report); + j["skinned"] = skinned; + result["rig"] = j; + return result; +} + QJsonObject MCPServer::toolGenerateMeshTexture(const QJsonObject &args) { #ifndef ENABLE_STABLE_DIFFUSION @@ -6320,6 +6406,35 @@ QJsonArray MCPServer::buildToolsList() ); } + // auto_rig (#407) + { + QJsonObject props; + props["template"] = QJsonObject{{"type", "string"}, + {"description", + "Skeleton template: 'humanoid' (19-bone, default), 'biped', " + "'quadruped', or 'generic' (3-joint spine fallback)."}}; + props["skin"] = QJsonObject{{"type", "boolean"}, + {"description", + "When true, also compute + apply skin weights so the mesh deforms " + "immediately (chains compute_skin_weights). Default false."}}; + props["up_axis"] = QJsonObject{{"type", "string"}, + {"description", "Mesh up axis: 'x', 'y' (default), or 'z'."}}; + props["output_path"] = QJsonObject{{"type", "string"}, + {"description", + "Optional path to re-export the rigged mesh. When omitted, the rig is " + "applied to the in-session scene only."}}; + appendTool( + "auto_rig", + "Auto-rig the currently selected STATIC (unrigged) mesh by embedding a " + "skeleton template into it (issue #407). Native heuristic (no external " + "deps): maps a proportional joint graph into the mesh AABB and recentres " + "joints toward the mesh's medial mass. Best on roughly upright, manifold, " + "T/A-pose meshes with +Y up. Already-skinned meshes are rejected. Pair " + "skin:true for a one-click rig+skin.", + props + ); + } + // generate_mesh_texture — only advertised when Stable Diffusion is // compiled in; the handler hard-fails otherwise, so publishing it on // a non-SD build would imply a capability the server can't satisfy. diff --git a/src/MCPServer.h b/src/MCPServer.h index 1704a343c..6de4d01c8 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -157,6 +157,9 @@ private slots: /// Issue #402: compute skin weights via inverse-distance /// heuristic. Mesh must have a skeleton attached. QJsonObject toolComputeSkinWeights(const QJsonObject &args); + /// #407: native auto-rig of the selected static mesh (template embedding), + /// optional skin chain + re-export. + QJsonObject toolAutoRig(const QJsonObject &args); /// Issue #403: mesh-aware (depth-conditioned) texture /// generation. Renders the selected entity's depth map and /// conditions sd.cpp on it via a ControlNet depth model, then diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index e03fc373e..a4b713ec6 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -12,6 +12,7 @@ ../qml/UvUnwrapDialog.qml ../qml/QuadRetopoDialog.qml ../qml/SkinWeightsDialog.qml + ../qml/AutoRigDialog.qml ../qml/IsometricSpritesDialog.qml ../qml/qmldir ../qml/ThemedButton.qml From fe1253447ba13176999e7356d8dda7a3d86c05db Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 11:59:33 -0400 Subject: [PATCH 03/24] fix(#407): compile AutoRig.cpp into the test common lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unit-tests-linux failed to link: AutoRig::* symbols undefined in libqtmesh_test_common.a (MCPServer::toolAutoRig and CLIPipeline::cmdRig reference them). The test target has its own TEST_SRC_FILES list separate from the app's src/CMakeLists.txt — add AutoRig.cpp + AutoRigController.cpp there, next to SkinWeights (same omission class as #738's PbrMapSynth gap). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2778fdb6a..cd3a57df8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -137,6 +137,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/QuadRetopoController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeights.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeightsController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AutoRig.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AutoRigController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshDepthRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshOptimizerLod.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ExportOptimizer.cpp From d0ef84e2d62ec0d26ab8e62003185a6dcb37959b Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 14:41:46 -0400 Subject: [PATCH 04/24] ci: re-trigger CI for #407 (missed synchronize webhook) Co-Authored-By: Claude Opus 4.8 (1M context) From 0b370c96e2dd4aea6a5389b54d8134102388ba35 Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Tue, 23 Jun 2026 15:44:30 -0400 Subject: [PATCH 05/24] fix(gui): blank QML panels + white models in installed builds (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gui): blank QML panels + white models in installed builds Two packaging/runtime bugs that only manifested in installed builds (Homebrew .app, .deb) — dev SDK runs masked both: 1. Blank white QML panels (Inspector / Context / Material docks). The Qt Quick *software* scene-graph backend was forced in the MainWindow ctor — AFTER QApplication, by which point Qt has already locked the default RHI. Moved QSG_RHI_BACKEND / setGraphicsApi(Software) to the top of main(), before QApplication (the only point where it takes effect). Replaced the now-dead call in mainwindow.cpp with a note. 2. White / untextured models in the macOS .app. Relative resource locations from resources.cfg were resolved against macBundlePath() (the .app bundle ROOT), but the media tree lives under Contents/MacOS/media (== applicationDirPath()). So .app/media/... didn't exist, Ogre loaded no RTSS GLSL programs or textures, and every mesh rendered flat white. Resolve relative paths against applicationDirPath() first (matches Linux), with the bundle root kept as a fallback; only add a location if it exists. Verified: a macdeployqt'd bundle now loads media from Contents/MacOS/media and renders the mage.glb fully textured. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: bump version to 3.9.1 (installed-build QML + macOS white-model fix) Bugfix release: QML software-backend set before QApplication, and macOS .app resource paths resolved against applicationDirPath() instead of the bundle root. Synced README + qtmesh action ref (verify-doc-versions gate). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 2 +- README.md | 22 ++++++------- src/Manager.cpp | 41 ++++++++++++++++++++----- src/main.cpp | 16 ++++++++++ src/mainwindow.cpp | 8 ++--- website/src/hooks/useQtmeshActionRef.js | 2 +- 6 files changed, 67 insertions(+), 24 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c8434cad..ec0015d4a 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 3.9.0 LANGUAGES C CXX) +project(QtMeshEditor VERSION 3.9.1 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/README.md b/README.md index 70e5bba28..f73041ef2 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act **Versioning** - **Always follow the latest GitHub release** — use the Marketplace floating tag `fernandotonon/QtMeshEditor@v1` (same pattern as the [Marketplace example](https://github.com/marketplace/actions/qtmesheditor)). The composite action defaults to `image-tag: latest`, so the Docker CLI tracks the newest published `ghcr.io/fernandotonon/qtmesh` image. -- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.9.0**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. +- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.9.1**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. Pinned workflow template (action + `ghcr.io` image aligned): @@ -53,10 +53,10 @@ jobs: - uses: actions/checkout@v4 - name: Run QtMesh scan - uses: fernandotonon/QtMeshEditor@3.9.0 + uses: fernandotonon/QtMeshEditor@3.9.1 with: command: scan - image-tag: "3.9.0" + image-tag: "3.9.1" env: QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }} ``` @@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/ ```yaml # Validate a specific mesh -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.1 with: command: validate input-file: ./models/character.fbx - image-tag: "3.9.0" + image-tag: "3.9.1" # Convert FBX → glTF -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.1 with: command: convert input-file: ./models/character.fbx output-file: ./output/character.gltf2 - image-tag: "3.9.0" + image-tag: "3.9.1" # Resample Mixamo animations (200+ keyframes → 30) -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.1 with: command: anim input-file: ./animations/dance.fbx output-file: ./output/dance_optimized.fbx options: --resample 30 - image-tag: "3.9.0" + image-tag: "3.9.1" # Get mesh info as JSON -- uses: fernandotonon/QtMeshEditor@3.9.0 +- uses: fernandotonon/QtMeshEditor@3.9.1 id: info with: command: info input-file: ./models/character.fbx options: --json - image-tag: "3.9.0" + image-tag: "3.9.1" # Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref) docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error diff --git a/src/Manager.cpp b/src/Manager.cpp index 80d1804a3..3c2cf2de7 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -969,19 +969,46 @@ void Manager::loadResources() { for (const auto& [typeName, archName] : settings) { + QString archPath = QString::fromStdString(archName); + if (QDir::isAbsolutePath(archPath)) { + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + archPath.toStdString(), typeName, secName); + continue; + } + // Resolve relative paths against the application directory so that // resources are found regardless of the current working directory - // (e.g., when launched from an installed .deb package). - QString archPath = QString::fromStdString(archName); - if (!QDir::isAbsolutePath(archPath)) { + // (installed .deb, .app bundle, dev build). Build candidate roots + // and pick the first that actually contains the path. + // + // On macOS the media/cfg tree lives under Contents/MacOS/ (== + // applicationDirPath()), NOT at the .app bundle root. The previous + // code resolved relative paths against macBundlePath() (the bundle + // root), so in an installed .app every relative resource location — + // including the RTSS GLSL programs and material textures — pointed + // at a non-existent .app/media/... directory. Ogre then loaded + // no shaders/textures and every mesh rendered flat WHITE. Resolving + // against applicationDirPath() first fixes it; the bundle-root path + // stays as a fallback for any older layout. (#bug: white models in + // Homebrew/installed builds, all platforms.) + QStringList roots; + roots << file; // applicationDirPath() #if OGRE_PLATFORM == OGRE_PLATFORM_APPLE - archPath = QString::fromStdString(macBundlePath()) + "/" + archPath; -#else - archPath = file + "/" + archPath; + roots << QString::fromStdString(macBundlePath()); // .app bundle root (legacy) #endif + QString resolved; + for (const QString& root : roots) { + const QString cand = root + "/" + archPath; + if (QFileInfo::exists(cand)) { resolved = cand; break; } } + // If none exist (e.g. an optional location), fall back to the first + // candidate so Ogre logs a clear "resource location not found" for it + // rather than silently skipping. + if (resolved.isEmpty()) + resolved = roots.first() + "/" + archPath; + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( - archPath.toStdString(), typeName, secName); + resolved.toStdString(), typeName, secName); } } diff --git a/src/main.cpp b/src/main.cpp index 4ed96da51..5bcb28a51 100755 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include "mainwindow.h" #include "MaterialEditorQML.h" #include "QMLMaterialHighlighter.h" @@ -141,6 +142,21 @@ int main(int argc, char *argv[]) // This prevents issues with native macOS style not supporting customization QQuickStyle::setStyle("Basic"); + // Force the Qt Quick *software* scene-graph backend BEFORE QApplication. + // Every QML surface (the Inspector / Context / Material QQuickWidgets in + // their docks, the ViewCube, etc.) runs software-rendered to avoid GL/Metal + // conflicts with Ogre's direct-to-native rendering. The MainWindow ctor used + // to set this, but by then Qt has already probed and locked the default RHI + // (Metal/GL) on first QQuickWidget init — too late. In a deployed .app the + // embedded dock QQuickWidgets then fail to composite and render BLANK WHITE + // (issue: Homebrew build shows white Inspector/Context panels) while a + // dev-SDK run happened to still paint. `QSGRendererInterface::setGraphicsApi` + // / `QSG_RHI_BACKEND` only take effect if set before the scene graph + // initialises, so they belong here, ahead of QApplication. + qputenv("QSG_RHI_BACKEND", "software"); + qputenv("QT_QUICK_BACKEND", "software"); + QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); + QApplication a(argc, argv); // Capture qDebug/qWarning/etc. from the rest of startup into the in-app console diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 05f767f8a..cafc1627a 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -595,10 +595,10 @@ void MainWindow::initToolBar() // QML Properties Panel (replaces old Transform tab with modern collapsible inspector) { - // Force software rendering before creating any QQuickWidget to avoid GL conflicts with Ogre - qputenv("QSG_RHI_BACKEND", "software"); - qputenv("QT_QUICK_BACKEND", "software"); - QQuickWindow::setGraphicsApi(QSGRendererInterface::Software); + // NOTE: the Qt Quick *software* scene-graph backend is forced in main() + // BEFORE QApplication (QSG_RHI_BACKEND / setGraphicsApi only take effect + // before the scene graph initialises). Setting it here was too late and + // left deployed-bundle dock QQuickWidgets rendering blank white. registerEditorModeQmlSingletons(); m_propertiesPanel = new QQuickWidget(); diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index 4ebd0abfe..a70fb5d1e 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.0'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.1'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; From bede436ea56d3a0c4febf5ccdd82b4a59a09d743 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 15:54:29 -0400 Subject: [PATCH 06/24] =?UTF-8?q?fix(#407):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20QML=20registration,=20upAxis,=20error=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review (Codex + CodeRabbit) on the auto-rig PR: - CRITICAL: register AutoRigController as a QML singleton in mainwindow.cpp (PropertiesPanel URI) like the sibling controllers + add its kill(). With qt_add_qml_module disabled, QML_SINGLETON alone doesn't expose it, so the Rigging section/dialog would ReferenceError. Verified no error at runtime now. - CRITICAL: the dialog's Up-axis picker was ignored — autoRigSelected() didn't take upAxis. Added a `const QString& upAxis` param (controller maps x/y/z → Options::upAxis) and pass dialog.upAxes[dialog.upAxisIndex] from QML. - AutoRig::appendPositions: guard a null vbuf->lock() (shrink `out` back, return false) instead of dereferencing. - AutoRig::rigEntity: on _initialise failure, detach the half-built skeleton (mesh->_notifySkeleton(null)) before removing it, so hasSkeleton() resets and a retry / exporter doesn't pick up a partial rig. - MCP toolAutoRig: validate output_path type; a requested skin that fails is now a hard error (no unskinned export reported as success); export wrapped in the try/catch (also catches std::exception); Sentry breadcrumb no longer logs the full output path. - PropertiesPanel openAutoRigDialog(): handle Loader.Error to allow retry. App + UnitTests build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- qml/AutoRigDialog.qml | 1 + qml/PropertiesPanel.qml | 4 +++ src/AutoRig.cpp | 13 ++++++++ src/AutoRigController.cpp | 10 ++++-- src/AutoRigController.h | 1 + src/MCPServer.cpp | 64 ++++++++++++++++++++++++--------------- src/mainwindow.cpp | 6 ++++ 7 files changed, 73 insertions(+), 26 deletions(-) diff --git a/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml index e3843a6e5..c6e8c5843 100644 --- a/qml/AutoRigDialog.qml +++ b/qml/AutoRigDialog.qml @@ -43,6 +43,7 @@ Window { if (!AutoRigController.hasRiggableSelection) return const r = AutoRigController.autoRigSelected( dialog.templates[dialog.templateIndex], + dialog.upAxes[dialog.upAxisIndex], dialog.alsoSkin) if (r && r.applied) { dialog.lastStatus = diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 2f4f4af2f..919d468ee 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4202,6 +4202,10 @@ Rectangle { autoRigLoader.active = true } else if (autoRigLoader.item) { autoRigLoader.item.open() + } else if (autoRigLoader.status === Loader.Error) { + // Failed load left active=true / item=null — reset so a retry works. + autoRigLoader.active = false + autoRigLoader.active = true } } diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp index 7cdb2abd3..e995cbf38 100644 --- a/src/AutoRig.cpp +++ b/src/AutoRig.cpp @@ -225,6 +225,12 @@ bool appendPositions(Ogre::VertexData* vd, std::vector& out) const size_t stride = vbuf->getVertexSize(); auto* base = static_cast( vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!base) { + // Lock can fail (write-only buffer with no shadow copy, etc.). Shrink + // back to the pre-grow size so the unread slots don't inflate vcount. + out.resize(base0); + return false; + } for (size_t i = 0; i < vd->vertexCount; ++i) { float* p = nullptr; posElem->baseVertexPointerToElement(base + i * stride, &p); @@ -330,6 +336,13 @@ AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) } catch (const Ogre::Exception& e) { report.error = QStringLiteral("Ogre error building skeleton: %1") .arg(QString::fromStdString(e.getFullDescription())); + // Detach the half-built skeleton from the mesh BEFORE removing the + // resource. _notifySkeleton(skel) ran before entity->_initialise; if + // the latter threw, the mesh still references the skeleton, so + // mesh->hasSkeleton() would stay true — a later rigEntity() would bail + // with "mesh already has a skeleton" and exporters could pick up the + // half-built rig. Reset it to a clean static mesh. + mesh->_notifySkeleton(Ogre::SkeletonPtr()); if (skel && skelMgr.resourceExists(skelName)) skelMgr.remove(skelName); report.applied = false; } diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp index 739d90d29..cfbe9abd3 100644 --- a/src/AutoRigController.cpp +++ b/src/AutoRigController.cpp @@ -51,13 +51,15 @@ bool AutoRigController::hasRiggableSelection() const } QVariantMap AutoRigController::autoRigSelected(const QString& templateName, + const QString& upAxis, bool alsoSkin) { QVariantMap result; SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), - QStringLiteral("Auto-rig requested (%1%2)") - .arg(templateName, alsoSkin ? QStringLiteral(", +skin") : QString())); + QStringLiteral("Auto-rig requested (%1, up=%2%3)") + .arg(templateName, upAxis, + alsoSkin ? QStringLiteral(", +skin") : QString())); auto* sel = SelectionSet::getSingleton(); const auto entities = sel ? sel->getResolvedEntities() : QList{}; @@ -79,6 +81,10 @@ QVariantMap AutoRigController::autoRigSelected(const QString& templateName, AutoRig::Options opts; opts.tmpl = AutoRig::templateFromString(templateName); + const QString ax = upAxis.trimmed().toLower(); + if (ax == QStringLiteral("x")) opts.upAxis = 0; + else if (ax == QStringLiteral("z")) opts.upAxis = 2; + else opts.upAxis = 1; // y (default) SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), QStringLiteral("UI auto-rig entity=%1 template=%2") diff --git a/src/AutoRigController.h b/src/AutoRigController.h index 7ddc8fee0..3a1ecfc9f 100644 --- a/src/AutoRigController.h +++ b/src/AutoRigController.h @@ -35,6 +35,7 @@ class AutoRigController : public QObject /// Returns a QVariantMap mirroring AutoRig::Report (+ a `skinned` bool). /// Emits `rigged(report)` on success or `error(msg)` on failure. Q_INVOKABLE QVariantMap autoRigSelected(const QString& templateName, + const QString& upAxis, bool alsoSkin); signals: diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 37caa75d7..77db11c48 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -1706,41 +1706,57 @@ QJsonObject MCPServer::toolAutoRig(const QJsonObject &args) AutoRig::templateToString(opts.tmpl)) .arg(alsoSkin)); + // Validate output_path type up front (like 'skin'/'template') — a + // non-string would otherwise coerce to "" and silently skip the export + // while still reporting success. + if (args.contains("output_path") && !args["output_path"].isString()) + return makeErrorResult("Error: 'output_path' must be a string."); + const QString outputPath = args.value("output_path").toString(); + AutoRig::Report report; bool skinned = false; + // Wrap the full mutating + export section so export failures and + // std::runtime_error (not just Ogre::Exception) reach the MCP error path. try { report = AutoRig::rigEntity(entity, opts); - if (report.applied && alsoSkin) { + if (!report.applied) + return makeErrorResult( + QStringLiteral("Auto-rig failed: %1").arg(report.error)); + + if (alsoSkin) { const auto sw = SkinWeights::computeAndApply(entity, {}); skinned = sw.applied; + // A requested skin that failed is a hard error — don't export an + // unskinned asset and report success. if (!sw.applied) - report.error = QStringLiteral("rigged, but skinning failed: %1") - .arg(sw.error); + return makeErrorResult(QStringLiteral( + "Auto-rig succeeded, but the requested skinning failed: %1") + .arg(sw.error)); + } + + // Optional re-export of the now-rigged mesh. + if (!outputPath.isEmpty()) { + Ogre::SceneNode* node = entity->getParentSceneNode(); + if (!node) + return makeErrorResult( + QStringLiteral("Error: rigged, but the entity has no scene " + "node to export from")); + // Don't leak the full local path (usernames / private dirs) to Sentry. + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("auto_rig export requested")); + const int rc = MeshImporterExporter::exporter( + node, outputPath, CLIPipeline::formatForExtension(outputPath)); + if (rc != 0) + return makeErrorResult( + QStringLiteral("Error: rigged but export to '%1' failed (code %2)") + .arg(outputPath).arg(rc)); } } catch (const Ogre::Exception& e) { return makeErrorResult(QStringLiteral("Ogre error: %1") .arg(QString::fromStdString(e.getFullDescription()))); - } - - if (!report.applied) - return makeErrorResult(QStringLiteral("Auto-rig failed: %1").arg(report.error)); - - // Optional re-export of the now-rigged mesh. - const QString outputPath = args["output_path"].toString(); - if (!outputPath.isEmpty()) { - Ogre::SceneNode* node = entity->getParentSceneNode(); - if (!node) - return makeErrorResult( - QStringLiteral("Error: rigged, but the entity has no scene node to " - "export from")); - SentryReporter::addBreadcrumb(QStringLiteral("file.export"), - QStringLiteral("auto_rig export to %1").arg(outputPath)); - const int rc = MeshImporterExporter::exporter( - node, outputPath, CLIPipeline::formatForExtension(outputPath)); - if (rc != 0) - return makeErrorResult( - QStringLiteral("Error: rigged but export to '%1' failed (code %2)") - .arg(outputPath).arg(rc)); + } catch (const std::exception& e) { + return makeErrorResult(QStringLiteral("Auto-rig error: %1") + .arg(QString::fromUtf8(e.what()))); } QJsonObject result = makeSuccessResult(AutoRig::reportToText(report)); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 05f767f8a..da8f9946a 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -101,6 +101,7 @@ #include "UvUnwrapController.h" #include "QuadRetopoController.h" #include "SkinWeightsController.h" +#include "AutoRigController.h" #include "MeshDepthRenderer.h" #include "MaterialPresetLibrary.h" #include "MaterialPreviewRenderer.h" @@ -649,6 +650,11 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return SkinWeightsController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "AutoRigController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return AutoRigController::qmlInstance(engine, nullptr); + }); #ifdef ENABLE_AUTO_UPDATER qmlRegisterSingletonType( "Updater", 1, 0, "UpdaterController", From 79a7c1ea2cecf7ea05d481e7b02315e428083092 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 15:57:07 -0400 Subject: [PATCH 07/24] ci: key macOS caches on the resolved Xcode version (fix libz.tbd mismatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-macos kept failing with "No rule to make target '.../Xcode_26.5.../libz.tbd'" even after the Pin-Xcode step: the producer (build-n-cache-ogre-macos) resolved "newest" to Xcode 26.3 on its runner image and cached OGRE with 26.3's absolute libz.tbd path, while the consumer (build-macos) resolved 26.5 and linked against the missing path. "newest" (sort -V | tail -1) is NOT deterministic across the per-job runner images. Fold the resolved Xcode app name into XCODE_TAG (exported by the Pin step) and append it to all macOS assimp/ogre cache keys + restore-keys. A consumer on a different Xcode now cache-misses and rebuilds OGRE/Assimp against its own SDK instead of linking a stale path. (Belongs on master too — same deploy.yml.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 41 ++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c2c918c61..dccadd879 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1580,6 +1580,15 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Fold the EXACT Xcode/SDK into the macOS cache key. "newest" + # (sort -V | tail -1) can resolve to DIFFERENT versions across the + # per-job runner images (producer built OGRE on 26.3, consumer linked + # on 26.5), and the cached OGRE/Assimp SDKs bake an absolute libz.tbd + # path for the SDK they were built against -> "No rule to make target + # '/libz.tbd'". Keying on the resolved version makes a consumer + # on a different Xcode cache-miss and rebuild against its own SDK. + XV=$(basename "$(dirname "$(dirname "$DEV")")" | tr -d '/ ') + echo "XCODE_TAG=${XV}" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1603,9 +1612,9 @@ jobs: /usr/local/lib/libzlibstatic.a #key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }} # Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo. - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- - if: steps.cache-assimp-macos.outputs.cache-hit != 'true' name: Check out Assimp repo @@ -1633,6 +1642,15 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Fold the EXACT Xcode/SDK into the macOS cache key. "newest" + # (sort -V | tail -1) can resolve to DIFFERENT versions across the + # per-job runner images (producer built OGRE on 26.3, consumer linked + # on 26.5), and the cached OGRE/Assimp SDKs bake an absolute libz.tbd + # path for the SDK they were built against -> "No rule to make target + # '/libz.tbd'". Keying on the resolved version makes a consumer + # on a different Xcode cache-miss and rebuild against its own SDK. + XV=$(basename "$(dirname "$(dirname "$DEV")")" | tr -d '/ ') + echo "XCODE_TAG=${XV}" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1654,9 +1672,9 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- - name: Cache Ogre id: cache-ogre-macos @@ -1665,7 +1683,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' name: Check out ogre repo @@ -1698,6 +1716,15 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Fold the EXACT Xcode/SDK into the macOS cache key. "newest" + # (sort -V | tail -1) can resolve to DIFFERENT versions across the + # per-job runner images (producer built OGRE on 26.3, consumer linked + # on 26.5), and the cached OGRE/Assimp SDKs bake an absolute libz.tbd + # path for the SDK they were built against -> "No rule to make target + # '/libz.tbd'". Keying on the resolved version makes a consumer + # on a different Xcode cache-miss and rebuild against its own SDK. + XV=$(basename "$(dirname "$(dirname "$DEV")")" | tr -d '/ ') + echo "XCODE_TAG=${XV}" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1752,7 +1779,7 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - name: Cache Ogre id: cache-ogre-macos @@ -1761,7 +1788,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - name: Configure CMake env: From 65d49e0a0e636f96485cb4d87c48f6963c65e66a Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 16:07:44 -0400 Subject: [PATCH 08/24] =?UTF-8?q?ci:=20bust=20stale=20macOS=20OGRE=20cache?= =?UTF-8?q?=20(xcode263)=20=E2=80=94=20fixes=20libz.tbd=20link=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-macos (incl. the 3.9.1 release deploy) failed: No rule to make target '.../Xcode_26.5/.../libz.tbd', needed by QtMeshEditor Diagnosis: the Pin-Xcode step reliably selects Xcode 26.3 on ALL macOS jobs (verified across runs), so compilation is consistent — but the restored OGRE cache was built earlier under Xcode 26.5 and its CMake export hardcodes 26.5's libz.tbd path. Restoring that into a 26.3 build breaks the link. Fix: bump MACOS_CACHE_VERSION xcode26b → xcode263 so OGRE/Assimp are rebuilt under the currently-pinned Xcode (26.3) and the stale 26.5 cache is discarded. Also reverted the earlier XCODE_TAG-in-cache-key experiment: build-macos only RESTORES the OGRE cache (no rebuild step), so a per-job Xcode-keyed miss would leave it with no OGRE at all ("Could not find OGRE"). With Xcode pinned consistently, a plain version bump is the correct, sufficient fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 54 ++++++++++-------------------------- 1 file changed, 15 insertions(+), 39 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dccadd879..bf7694992 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,11 +23,14 @@ env: ASSIMP_DIR_VERSION: '6.0' OGRE_VERSION: '14.5.2' # Bump to bust the macOS assimp/ogre caches. The cached OGRE/Assimp SDKs bake - # absolute Xcode SDK paths (e.g. .../usr/lib/libz.tbd) into their CMake export; - # when the macos-latest runner image bumps Xcode, a stale cache hit makes - # build-macos fail with "No rule to make target '/libz.tbd'". Bump - # this whenever the runner's Xcode/SDK changes. - MACOS_CACHE_VERSION: 'xcode26b' + # an absolute Xcode SDK path (e.g. .../usr/lib/libz.tbd) into their CMake + # export. The Pin-Xcode step below now selects a CONSISTENT Xcode across all + # macOS jobs (currently 26.3), but a cache built earlier under a different + # Xcode (26.5) still carries that old libz.tbd path and, when restored into a + # 26.3 build, fails with "No rule to make target '.../Xcode_26.5/...libz.tbd'". + # Bump this whenever the pinned Xcode changes so the SDK is rebuilt against it. + # (xcode263 = rebuilt under the Pin step's Xcode 26.3.) + MACOS_CACHE_VERSION: 'xcode263' jobs: # send-slack-notification: @@ -1580,15 +1583,6 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" - # Fold the EXACT Xcode/SDK into the macOS cache key. "newest" - # (sort -V | tail -1) can resolve to DIFFERENT versions across the - # per-job runner images (producer built OGRE on 26.3, consumer linked - # on 26.5), and the cached OGRE/Assimp SDKs bake an absolute libz.tbd - # path for the SDK they were built against -> "No rule to make target - # '/libz.tbd'". Keying on the resolved version makes a consumer - # on a different Xcode cache-miss and rebuild against its own SDK. - XV=$(basename "$(dirname "$(dirname "$DEV")")" | tr -d '/ ') - echo "XCODE_TAG=${XV}" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1612,9 +1606,9 @@ jobs: /usr/local/lib/libzlibstatic.a #key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }} # Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo. - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- - if: steps.cache-assimp-macos.outputs.cache-hit != 'true' name: Check out Assimp repo @@ -1642,15 +1636,6 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" - # Fold the EXACT Xcode/SDK into the macOS cache key. "newest" - # (sort -V | tail -1) can resolve to DIFFERENT versions across the - # per-job runner images (producer built OGRE on 26.3, consumer linked - # on 26.5), and the cached OGRE/Assimp SDKs bake an absolute libz.tbd - # path for the SDK they were built against -> "No rule to make target - # '/libz.tbd'". Keying on the resolved version makes a consumer - # on a different Xcode cache-miss and rebuild against its own SDK. - XV=$(basename "$(dirname "$(dirname "$DEV")")" | tr -d '/ ') - echo "XCODE_TAG=${XV}" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1672,9 +1657,9 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- - name: Cache Ogre id: cache-ogre-macos @@ -1683,7 +1668,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' name: Check out ogre repo @@ -1716,15 +1701,6 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" - # Fold the EXACT Xcode/SDK into the macOS cache key. "newest" - # (sort -V | tail -1) can resolve to DIFFERENT versions across the - # per-job runner images (producer built OGRE on 26.3, consumer linked - # on 26.5), and the cached OGRE/Assimp SDKs bake an absolute libz.tbd - # path for the SDK they were built against -> "No rule to make target - # '/libz.tbd'". Keying on the resolved version makes a consumer - # on a different Xcode cache-miss and rebuild against its own SDK. - XV=$(basename "$(dirname "$(dirname "$DEV")")" | tr -d '/ ') - echo "XCODE_TAG=${XV}" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1779,7 +1755,7 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} - name: Cache Ogre id: cache-ogre-macos @@ -1788,7 +1764,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} - name: Configure CMake env: From 8329bf653d88076c4ecbe9468d825f6b5f693098 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 16:33:19 -0400 Subject: [PATCH 09/24] ci: self-heal macOS OGRE cache across differing per-job Xcode images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the build-macos libz.tbd failure: the producer (build-n-cache-ogre-macos) and consumer (build-macos) run on DIFFERENT runner images whose "newest Xcode" differs — producer resolved Xcode 26.5 and cached OGRE with 26.5's absolute libz.tbd path baked into its CMake export; consumer resolved 26.3 and linked against the missing 26.5 path. Just pinning "newest" or bumping the cache version doesn't help because the two images disagree. Fix (self-healing): - Fold the resolved Xcode app name into XCODE_TAG and append it to all macOS assimp/ogre cache keys + restore-keys, so a job only restores a cache built under its OWN Xcode. - Give build-macos (consumer) the same "check out + build OGRE on cache miss" steps the producer has. When the consumer's Xcode differs from the producer's (cache miss), it rebuilds OGRE under its own SDK instead of failing on a stale libz.tbd path. This makes the macOS build robust regardless of which Xcode each runner image ships. (Bigger than the earlier one-line bump, but that couldn't fix a cross-image Xcode disagreement.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 68 ++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bf7694992..7dbc8eb72 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1583,6 +1583,16 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). The + # OGRE/Assimp SDKs bake the active SDK's absolute libz.tbd path into + # their CMake export, so a cache built under one Xcode can't be + # linked under another. Fold the resolved Xcode app into the cache + # key so each job only restores a cache built under its OWN Xcode; + # build-macos rebuilds OGRE on a miss (steps below) so a mismatch + # self-heals instead of failing with "No rule to make target + # '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1606,9 +1616,9 @@ jobs: /usr/local/lib/libzlibstatic.a #key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }} # Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo. - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- - if: steps.cache-assimp-macos.outputs.cache-hit != 'true' name: Check out Assimp repo @@ -1636,6 +1646,16 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). The + # OGRE/Assimp SDKs bake the active SDK's absolute libz.tbd path into + # their CMake export, so a cache built under one Xcode can't be + # linked under another. Fold the resolved Xcode app into the cache + # key so each job only restores a cache built under its OWN Xcode; + # build-macos rebuilds OGRE on a miss (steps below) so a mismatch + # self-heals instead of failing with "No rule to make target + # '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1657,9 +1677,9 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- - name: Cache Ogre id: cache-ogre-macos @@ -1668,7 +1688,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' name: Check out ogre repo @@ -1701,6 +1721,16 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). The + # OGRE/Assimp SDKs bake the active SDK's absolute libz.tbd path into + # their CMake export, so a cache built under one Xcode can't be + # linked under another. Fold the resolved Xcode app into the cache + # key so each job only restores a cache built under its OWN Xcode; + # build-macos rebuilds OGRE on a miss (steps below) so a mismatch + # self-heals instead of failing with "No rule to make target + # '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1755,7 +1785,7 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - name: Cache Ogre id: cache-ogre-macos @@ -1764,7 +1794,31 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + + # If this runner image's Xcode differs from the one the producer cached + # under, the key above misses. Rebuild OGRE here under THIS job's Xcode so + # the SDK's baked libz.tbd path matches what we link against (self-heals the + # cross-image Xcode mismatch instead of failing on a stale libz.tbd path). + - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' + name: Check out ogre repo (cache miss) + uses: actions/checkout@master + with: + repository: OGRECave/ogre + ref: v${{ env.OGRE_VERSION }} + path: ${{github.workspace}}/ogre + + - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' + name: Build Ogre3D repo (cache miss) + run: | + cd ${{github.workspace}}/ogre/ + sudo cmake -S . -DOGRE_BUILD_PLUGIN_ASSIMP=ON -Dassimp_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }}/ \ + -DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON -DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON \ + -DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF -DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF \ + -DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF -DOGRE_BUILD_COMPONENT_PYTHON=OFF \ + -DOGRE_INSTALL_TOOLS=OFF -DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF -DOGRE_BUILD_LIBS_AS_FRAMEWORKS=OFF \ + -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} + sudo make install -j8 - name: Configure CMake env: From b8d515946d536ba97e407507e27617436038e283 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 16:56:15 -0400 Subject: [PATCH 10/24] ci: keep assimp macOS cache Xcode-agnostic (only ogre is Xcode-keyed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit Xcode-keyed BOTH the assimp and ogre macOS caches. That broke build-macos on a runner whose Xcode differed from the producer's: assimp cache-missed (no assimp-build-on-miss exists) so find_package(assimp) failed with "Could not find a package configuration file provided by assimp". Assimp is a plain static lib that doesn't bake absolute SDK paths, so one assimp cache is valid across Xcode versions — revert XCODE_TAG on the 3 assimp keys, keeping it ONLY on the 2 ogre keys (ogre's CMake export DOES bake an absolute libz.tbd path, which is why ogre needs per-Xcode keying + the consumer's rebuild-on-miss). The shared assimp cache is then always present for the ogre rebuild to link against. Verified on the failing run: Qt + OGRE now resolve and link (no libz.tbd error); this removes the remaining assimp-not-found failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7dbc8eb72..8a5127b4e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1616,9 +1616,12 @@ jobs: /usr/local/lib/libzlibstatic.a #key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }} # Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo. - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + # NOTE: assimp is NOT Xcode-keyed (unlike ogre): it's a plain static lib + # that doesn't bake absolute SDK paths, so one assimp cache works across + # Xcode versions and stays shared so the ogre-rebuild-on-miss can use it. + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- - if: steps.cache-assimp-macos.outputs.cache-hit != 'true' name: Check out Assimp repo @@ -1677,9 +1680,9 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}- + ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- - name: Cache Ogre id: cache-ogre-macos @@ -1785,7 +1788,7 @@ jobs: /usr/local/lib/pkgconfig/assimp.pc /usr/local/lib/libassimp* /usr/local/lib/libzlibstatic.a - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} - name: Cache Ogre id: cache-ogre-macos From 142da631969205ca03aeba698edaee383e1258b1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 17:50:58 -0400 Subject: [PATCH 11/24] test: fix flaky MainWindowTest.ModeBarLoadsAndModeChange (show window first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This test failed intermittently on CI (Xvfb) with: Value of: window->m_modeBarShell->isHidden() Actual: true Expected: false The fixture constructs MainWindow but never show()s it. QToolBar::isHidden() reflects effective visibility, which is only realized once the parent window is mapped — so under Xvfb the shell reports hidden and the assertion is racy. It hit BOTH this branch and the unrelated CI-only PR #756 (which has no source changes), confirming it's a pre-existing flake, not a regression. Fix: show() the window and processEvents() before the visibility assertion. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mainwindow_test.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 420dfee97..1f99f5325 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -282,6 +282,13 @@ TEST_F(MainWindowTest, ModeBarLoadsAndModeChangeUpdatesStatusIndicator) ASSERT_EQ(window->m_modeBar->status(), QQuickWidget::Ready); EXPECT_GE(window->m_modeBar->minimumWidth(), 560); EXPECT_EQ(window->toolBarArea(window->m_modeBarShell), Qt::TopToolBarArea); + // QToolBar::isHidden() reflects effective visibility, which is only + // meaningful once the parent window has been shown. The fixture constructs + // MainWindow without show()ing it, so under Xvfb this assertion was flaky + // (the shell reports hidden until the window is mapped). Show the window and + // drain events so the toolbar's visibility is realized before asserting. + window->show(); + app->processEvents(); EXPECT_FALSE(window->m_modeBarShell->isHidden()); ASSERT_NE(window->m_editModeLabel, nullptr); From 4fe583f873f57c5826ee155623a92b9a1ca52ece Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 18:05:43 -0400 Subject: [PATCH 12/24] ci: pin SDKROOT so CMake ZLIB resolves under the selected Xcode (macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-macos still failed with the Xcode_26.5 libz.tbd path even after pinning DEVELOPER_DIR=Xcode_26.3 and rebuilding OGRE: CMake's find_package(ZLIB) resolved to the SDK that `xcrun` defaults to (26.5 on these images) rather than the xcode-select'd one, so the OGRE SDK's CMake export baked a 26.5 libz.tbd path that the cache then carried forward. Fix: export SDKROOT (from `xcrun --sdk macosx --show-sdk-path` under the pinned Xcode) in the Pin step, so clang AND CMake resolve system libs under the SAME pinned SDK on every macOS job. Bump MACOS_CACHE_VERSION → sdkpin1 to discard the OGRE caches that still carry the 26.5 path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 79 ++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a5127b4e..ba8aeff43 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,13 +24,12 @@ env: OGRE_VERSION: '14.5.2' # Bump to bust the macOS assimp/ogre caches. The cached OGRE/Assimp SDKs bake # an absolute Xcode SDK path (e.g. .../usr/lib/libz.tbd) into their CMake - # export. The Pin-Xcode step below now selects a CONSISTENT Xcode across all - # macOS jobs (currently 26.3), but a cache built earlier under a different - # Xcode (26.5) still carries that old libz.tbd path and, when restored into a - # 26.3 build, fails with "No rule to make target '.../Xcode_26.5/...libz.tbd'". - # Bump this whenever the pinned Xcode changes so the SDK is rebuilt against it. - # (xcode263 = rebuilt under the Pin step's Xcode 26.3.) - MACOS_CACHE_VERSION: 'xcode263' + # export. The Pin-Xcode step also pins SDKROOT so CMake's ZLIB resolves under + # the selected Xcode (xcode-select alone didn't stop find_package(ZLIB) from + # picking xcrun's default 26.5 SDK). Bump this whenever the pinned Xcode/SDK + # changes so the SDK is rebuilt against it and stale libz.tbd paths are + # discarded. (sdkpin1 = first build under the SDKROOT-pinned environment.) + MACOS_CACHE_VERSION: 'sdkpin1' jobs: # send-slack-notification: @@ -1583,15 +1582,21 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" # The per-job runner images can carry DIFFERENT newest Xcodes - # (e.g. producer image has 26.5, consumer image only 26.3). The - # OGRE/Assimp SDKs bake the active SDK's absolute libz.tbd path into - # their CMake export, so a cache built under one Xcode can't be - # linked under another. Fold the resolved Xcode app into the cache - # key so each job only restores a cache built under its OWN Xcode; - # build-macos rebuilds OGRE on a miss (steps below) so a mismatch - # self-heals instead of failing with "No rule to make target - # '.../Xcode_XX/...libz.tbd'". + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions @@ -1649,15 +1654,21 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" # The per-job runner images can carry DIFFERENT newest Xcodes - # (e.g. producer image has 26.5, consumer image only 26.3). The - # OGRE/Assimp SDKs bake the active SDK's absolute libz.tbd path into - # their CMake export, so a cache built under one Xcode can't be - # linked under another. Fold the resolved Xcode app into the cache - # key so each job only restores a cache built under its OWN Xcode; - # build-macos rebuilds OGRE on a miss (steps below) so a mismatch - # self-heals instead of failing with "No rule to make target - # '.../Xcode_XX/...libz.tbd'". + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions @@ -1724,15 +1735,21 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" # The per-job runner images can carry DIFFERENT newest Xcodes - # (e.g. producer image has 26.5, consumer image only 26.3). The - # OGRE/Assimp SDKs bake the active SDK's absolute libz.tbd path into - # their CMake export, so a cache built under one Xcode can't be - # linked under another. Fold the resolved Xcode app into the cache - # key so each job only restores a cache built under its OWN Xcode; - # build-macos rebuilds OGRE on a miss (steps below) so a mismatch - # self-heals instead of failing with "No rule to make target - # '.../Xcode_XX/...libz.tbd'". + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions From dc038965a67e3b9513216eecb0b5371ee01393d8 Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Tue, 23 Jun 2026 18:41:59 -0400 Subject: [PATCH 13/24] =?UTF-8?q?ci:=20bust=20stale=20macOS=20OGRE=20cache?= =?UTF-8?q?=20(xcode263)=20=E2=80=94=20unblock=203.9.1=20macOS=20deploy=20?= =?UTF-8?q?(#756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: bust stale macOS OGRE cache (xcode263) — unblock the macOS deploy The 3.9.1 release deploy failed on build-macos: No rule to make target '.../Xcode_26.5/.../libz.tbd', needed by QtMeshEditor The Pin-Xcode step selects Xcode 26.3 consistently on all macOS jobs, but the OGRE cache under key 'xcode26b' was built earlier under Xcode 26.5 and its CMake export hardcodes 26.5's libz.tbd path. Restoring it into a 26.3 build breaks the link. Bump MACOS_CACHE_VERSION xcode26b → xcode263 so OGRE/Assimp rebuild under the pinned 26.3 and the stale cache is discarded. (Windows + Linux .deb artifacts already published for 3.9.1; this lets the macOS artifact + Homebrew cask update complete on a deploy re-run.) Co-Authored-By: Claude Opus 4.8 (1M context) * ci: self-heal macOS OGRE cache across differing per-job Xcode images The real cause of the build-macos libz.tbd failure: the producer (build-n-cache-ogre-macos) and consumer (build-macos) run on DIFFERENT runner images whose "newest Xcode" differs — producer resolved Xcode 26.5 and cached OGRE with 26.5's absolute libz.tbd path baked into its CMake export; consumer resolved 26.3 and linked against the missing 26.5 path. Just pinning "newest" or bumping the cache version doesn't help because the two images disagree. Fix (self-healing): - Fold the resolved Xcode app name into XCODE_TAG and append it to all macOS assimp/ogre cache keys + restore-keys, so a job only restores a cache built under its OWN Xcode. - Give build-macos (consumer) the same "check out + build OGRE on cache miss" steps the producer has. When the consumer's Xcode differs from the producer's (cache miss), it rebuilds OGRE under its own SDK instead of failing on a stale libz.tbd path. This makes the macOS build robust regardless of which Xcode each runner image ships. (Bigger than the earlier one-line bump, but that couldn't fix a cross-image Xcode disagreement.) Co-Authored-By: Claude Opus 4.8 (1M context) * ci: keep assimp macOS cache Xcode-agnostic (only ogre is Xcode-keyed) Previous commit Xcode-keyed BOTH the assimp and ogre macOS caches. That broke build-macos on a runner whose Xcode differed from the producer's: assimp cache-missed (no assimp-build-on-miss exists) so find_package(assimp) failed with "Could not find a package configuration file provided by assimp". Assimp is a plain static lib that doesn't bake absolute SDK paths, so one assimp cache is valid across Xcode versions — revert XCODE_TAG on the 3 assimp keys, keeping it ONLY on the 2 ogre keys (ogre's CMake export DOES bake an absolute libz.tbd path, which is why ogre needs per-Xcode keying + the consumer's rebuild-on-miss). The shared assimp cache is then always present for the ogre rebuild to link against. Verified on the failing run: Qt + OGRE now resolve and link (no libz.tbd error); this removes the remaining assimp-not-found failure. Co-Authored-By: Claude Opus 4.8 (1M context) * test: fix flaky MainWindowTest.ModeBarLoadsAndModeChange (show window first) This test failed intermittently on CI (Xvfb) with: Value of: window->m_modeBarShell->isHidden() Actual: true Expected: false The fixture constructs MainWindow but never show()s it. QToolBar::isHidden() reflects effective visibility, which is only realized once the parent window is mapped — so under Xvfb the shell reports hidden and the assertion is racy. It hit BOTH this branch and the unrelated CI-only PR #756 (which has no source changes), confirming it's a pre-existing flake, not a regression. Fix: show() the window and processEvents() before the visibility assertion. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: pin SDKROOT so CMake ZLIB resolves under the selected Xcode (macOS) build-macos still failed with the Xcode_26.5 libz.tbd path even after pinning DEVELOPER_DIR=Xcode_26.3 and rebuilding OGRE: CMake's find_package(ZLIB) resolved to the SDK that `xcrun` defaults to (26.5 on these images) rather than the xcode-select'd one, so the OGRE SDK's CMake export baked a 26.5 libz.tbd path that the cache then carried forward. Fix: export SDKROOT (from `xcrun --sdk macosx --show-sdk-path` under the pinned Xcode) in the Pin step, so clang AND CMake resolve system libs under the SAME pinned SDK on every macOS job. Bump MACOS_CACHE_VERSION → sdkpin1 to discard the OGRE caches that still carry the 26.5 path. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 92 +++++++++++++++++++++++++++++++++--- src/mainwindow_test.cpp | 7 +++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c2c918c61..6e5f79974 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -23,11 +23,14 @@ env: ASSIMP_DIR_VERSION: '6.0' OGRE_VERSION: '14.5.2' # Bump to bust the macOS assimp/ogre caches. The cached OGRE/Assimp SDKs bake - # absolute Xcode SDK paths (e.g. .../usr/lib/libz.tbd) into their CMake export; - # when the macos-latest runner image bumps Xcode, a stale cache hit makes - # build-macos fail with "No rule to make target '/libz.tbd'". Bump - # this whenever the runner's Xcode/SDK changes. - MACOS_CACHE_VERSION: 'xcode26b' + # an absolute Xcode SDK path (e.g. .../usr/lib/libz.tbd) into their CMake + # export. The Pin-Xcode step below selects a CONSISTENT Xcode across all macOS + # jobs (currently 26.3), but a cache built earlier under a different Xcode + # (26.5) still carries that old libz.tbd path and, when restored into a 26.3 + # build, fails with "No rule to make target '.../Xcode_26.5/...libz.tbd'" + # (this broke the 3.9.1 macOS deploy). Bump this whenever the pinned Xcode + # changes so the SDK is rebuilt against it. (xcode263 = under Pin step Xcode 26.3.) + MACOS_CACHE_VERSION: 'sdkpin1' jobs: # send-slack-notification: @@ -1580,6 +1583,22 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1603,6 +1622,9 @@ jobs: /usr/local/lib/libzlibstatic.a #key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }} # Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo. + # NOTE: assimp is NOT Xcode-keyed (unlike ogre): it's a plain static lib + # that doesn't bake absolute SDK paths, so one assimp cache works across + # Xcode versions and stays shared so the ogre-rebuild-on-miss can use it. key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} restore-keys: | ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}- @@ -1633,6 +1655,22 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1665,7 +1703,7 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' name: Check out ogre repo @@ -1698,6 +1736,22 @@ jobs: echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV" + # Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop + # CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun` + # defaults to (on these images that was Xcode 26.5 even with 26.3 + # selected), so OGRE's CMake export baked a 26.5 libz.tbd path that + # then failed to link under 26.3. Exporting SDKROOT makes clang AND + # CMake resolve system libs under the SAME pinned SDK everywhere. + SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)" + [ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV" + echo "Pinned SDKROOT: $SDKROOT_PATH" + # The per-job runner images can carry DIFFERENT newest Xcodes + # (e.g. producer image has 26.5, consumer image only 26.3). Fold the + # resolved Xcode app into the cache key so each job only restores a + # cache built under its OWN Xcode; build-macos rebuilds OGRE on a + # miss (steps below) so a mismatch self-heals instead of failing + # with "No rule to make target '.../Xcode_XX/...libz.tbd'". + echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV" - name: change folder permissions run: | @@ -1761,7 +1815,31 @@ jobs: cache-name: cache-ogre-macos with: path: ${{github.workspace}}/ogre/SDK - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }} + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }} + + # If this runner image's Xcode differs from the one the producer cached + # under, the key above misses. Rebuild OGRE here under THIS job's Xcode so + # the SDK's baked libz.tbd path matches what we link against (self-heals the + # cross-image Xcode mismatch instead of failing on a stale libz.tbd path). + - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' + name: Check out ogre repo (cache miss) + uses: actions/checkout@master + with: + repository: OGRECave/ogre + ref: v${{ env.OGRE_VERSION }} + path: ${{github.workspace}}/ogre + + - if: steps.cache-ogre-macos.outputs.cache-hit != 'true' + name: Build Ogre3D repo (cache miss) + run: | + cd ${{github.workspace}}/ogre/ + sudo cmake -S . -DOGRE_BUILD_PLUGIN_ASSIMP=ON -Dassimp_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }}/ \ + -DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON -DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON \ + -DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF -DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF \ + -DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF -DOGRE_BUILD_COMPONENT_PYTHON=OFF \ + -DOGRE_INSTALL_TOOLS=OFF -DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF -DOGRE_BUILD_LIBS_AS_FRAMEWORKS=OFF \ + -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} + sudo make install -j8 - name: Configure CMake env: diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 420dfee97..1f99f5325 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -282,6 +282,13 @@ TEST_F(MainWindowTest, ModeBarLoadsAndModeChangeUpdatesStatusIndicator) ASSERT_EQ(window->m_modeBar->status(), QQuickWidget::Ready); EXPECT_GE(window->m_modeBar->minimumWidth(), 560); EXPECT_EQ(window->toolBarArea(window->m_modeBarShell), Qt::TopToolBarArea); + // QToolBar::isHidden() reflects effective visibility, which is only + // meaningful once the parent window has been shown. The fixture constructs + // MainWindow without show()ing it, so under Xvfb this assertion was flaky + // (the shell reports hidden until the window is mapped). Show the window and + // drain events so the toolbar's visibility is realized before asserting. + window->show(); + app->processEvents(); EXPECT_FALSE(window->m_modeBarShell->isHidden()); ASSERT_NE(window->m_editModeLabel, nullptr); From 486704c468b2a00a546f79181b35cf75d97c7bd2 Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Tue, 23 Jun 2026 19:41:54 -0400 Subject: [PATCH 14/24] ci: pin EXACT Xcode 26.3 on all macOS jobs (stop per-image newest drift) (#758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even with SDKROOT pinned, build-macos failed because the macOS jobs ran on runner images with different *newest* Xcodes: the ogre/assimp producers sometimes resolved Xcode 26.5 (newest on their image) and cached an SDK whose Codec_Assimp/ogre CMake export hardcodes 26.5's libz.tbd path, while the consumer (26.3) couldn't link it. "sort -V | tail -1" is non-deterministic across images. Pin a SPECIFIC Xcode (26.3) that's present on all current macos-latest images, falling back to newest only if absent — so producers and consumer always agree on the SDK. Bump MACOS_CACHE_VERSION → xc263pin to discard the assimp+ogre caches that still carry a 26.5 path. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 37 +++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6e5f79974..9bcfa5efa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,7 +30,7 @@ env: # build, fails with "No rule to make target '.../Xcode_26.5/...libz.tbd'" # (this broke the 3.9.1 macOS deploy). Bump this whenever the pinned Xcode # changes so the SDK is rebuilt against it. (xcode263 = under Pin step Xcode 26.3.) - MACOS_CACHE_VERSION: 'sdkpin1' + MACOS_CACHE_VERSION: 'xc263pin' jobs: # send-slack-notification: @@ -1574,11 +1574,20 @@ jobs: steps: - name: Pin newest stable Xcode (consistent SDK across all macOS jobs) run: | - # All three macOS jobs must use the SAME Xcode/SDK: the OGRE/Assimp + # All macOS jobs MUST use the SAME exact Xcode/SDK: the OGRE/Assimp # builds bake the active SDK's absolute libz.tbd path into their - # CMake export, and the consumer build links against it. Different - # default Xcodes per job → "No rule to make target '/libz.tbd'". - DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1) + # CMake export, and the consumer links against it. The per-job runner + # images carry different *newest* Xcodes (one job got 26.5, another + # 26.3), so "sort -V | tail -1" (newest) made jobs disagree and the + # cached SDK's baked path failed to link → "No rule to make target + # '.../Xcode_XX/...libz.tbd'". Pin a SPECIFIC version present on all + # current images (26.3); fall back to newest only if it's absent. + PIN="/Applications/Xcode_26.3.app/Contents/Developer" + if [ -d "$PIN" ]; then + DEV="$PIN" + else + DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1) + fi if [ -z "$DEV" ]; then DEV="$(xcode-select -p)"; fi echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" @@ -1650,7 +1659,14 @@ jobs: steps: - name: Pin newest stable Xcode (consistent SDK across all macOS jobs) run: | - DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1) + # Pin a SPECIFIC Xcode present on all macos-latest images (26.3) so + # producers and consumer agree on the SDK; "newest" drifts per image. + PIN="/Applications/Xcode_26.3.app/Contents/Developer" + if [ -d "$PIN" ]; then + DEV="$PIN" + else + DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1) + fi if [ -z "$DEV" ]; then DEV="$(xcode-select -p)"; fi echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" @@ -1731,7 +1747,14 @@ jobs: steps: - name: Pin newest stable Xcode (consistent SDK across all macOS jobs) run: | - DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1) + # Pin a SPECIFIC Xcode present on all macos-latest images (26.3) so + # producers and consumer agree on the SDK; "newest" drifts per image. + PIN="/Applications/Xcode_26.3.app/Contents/Developer" + if [ -d "$PIN" ]; then + DEV="$PIN" + else + DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1) + fi if [ -z "$DEV" ]; then DEV="$(xcode-select -p)"; fi echo "Selected Xcode: $DEV" sudo xcode-select -s "$DEV" From db5a1df9d6cdba33c5c6eb1855594a7f3f7b67df Mon Sep 17 00:00:00 2001 From: Fernando Tonon Date: Tue, 23 Jun 2026 21:46:26 -0400 Subject: [PATCH 15/24] =?UTF-8?q?fix(import):=20restore=20textured=20viewp?= =?UTF-8?q?ort=20and=20paint=20preview=20on=20File=E2=86=92Open=20(#757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(import): restore textured viewport and paint preview on File→Open Rebind RTSS materials for newly imported entities (same path cloud downloads already used) and load texture-paint buffers from embedded/disk sources before GPU readback, which fails for many imported FBX textures on Linux. Co-authored-by: Cursor * fix(import): scope texture rebind per entity source file Use each mesh's qtme.source_path binding for sidecar texture lookup so multi-file File→Open imports cannot cross-bind common texture basenames. Co-authored-by: Cursor * chore: bump version to 3.9.2 Sync README and website pinned refs via sync-doc-versions-from-cmake.sh. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- CMakeLists.txt | 2 +- README.md | 22 ++--- src/MeshImporterExporter.cpp | 39 +++++++- src/MeshImporterExporter.h | 6 ++ src/TexturePaintController.cpp | 125 +++++++++++++++++++++--- src/mainwindow.cpp | 65 +++++++----- website/src/hooks/useQtmeshActionRef.js | 2 +- 7 files changed, 210 insertions(+), 51 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ec0015d4a..8ef04fe4b 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 3.9.1 LANGUAGES C CXX) +project(QtMeshEditor VERSION 3.9.2 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/README.md b/README.md index f73041ef2..02325cf51 100755 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Available on the [GitHub Actions Marketplace](https://github.com/marketplace/act **Versioning** - **Always follow the latest GitHub release** — use the Marketplace floating tag `fernandotonon/QtMeshEditor@v1` (same pattern as the [Marketplace example](https://github.com/marketplace/actions/qtmesheditor)). The composite action defaults to `image-tag: latest`, so the Docker CLI tracks the newest published `ghcr.io/fernandotonon/qtmesh` image. -- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.9.1**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. +- **Reproducible builds** — pin the action and the container to the same semver as this repository’s `project(QtMeshEditor VERSION …)` in `CMakeLists.txt` (currently **3.9.2**). After bumping the version in CMake, run `./scripts/sync-doc-versions-from-cmake.sh` to refresh the pinned refs in `README.md` and the docs site fallback; CI enforces the match with `./scripts/sync-doc-versions-from-cmake.sh --check`. Pinned workflow template (action + `ghcr.io` image aligned): @@ -53,10 +53,10 @@ jobs: - uses: actions/checkout@v4 - name: Run QtMesh scan - uses: fernandotonon/QtMeshEditor@3.9.1 + uses: fernandotonon/QtMeshEditor@3.9.2 with: command: scan - image-tag: "3.9.1" + image-tag: "3.9.2" env: QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }} ``` @@ -81,37 +81,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/ ```yaml # Validate a specific mesh -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: validate input-file: ./models/character.fbx - image-tag: "3.9.1" + image-tag: "3.9.2" # Convert FBX → glTF -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: convert input-file: ./models/character.fbx output-file: ./output/character.gltf2 - image-tag: "3.9.1" + image-tag: "3.9.2" # Resample Mixamo animations (200+ keyframes → 30) -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 with: command: anim input-file: ./animations/dance.fbx output-file: ./output/dance_optimized.fbx options: --resample 30 - image-tag: "3.9.1" + image-tag: "3.9.2" # Get mesh info as JSON -- uses: fernandotonon/QtMeshEditor@3.9.1 +- uses: fernandotonon/QtMeshEditor@3.9.2 id: info with: command: info input-file: ./models/character.fbx options: --json - image-tag: "3.9.1" + image-tag: "3.9.2" # Docker (alternative — :latest tracks newest image; pin :3.4.0 to match semver action ref) docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index cf3042df0..3ab54b543 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -1669,13 +1669,44 @@ QString cloudProjectCacheRoot(const QString& localPath) return normalized.left(cloudIdx + marker.size() + slugEnd); } -void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile) +QStringList MeshImporterExporter::textureSearchRootsForImportFile(const QString& localPath) { - const QFileInfo fileInfo(localMainFile); - registerImportResourceDirectory(fileInfo.absolutePath()); + QStringList roots; + const QFileInfo fileInfo(localPath); + if (!fileInfo.exists()) + return roots; + + roots << fileInfo.absolutePath(); const QString cloudRoot = cloudProjectCacheRoot(fileInfo.absoluteFilePath()); if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath()) - registerImportResourceDirectory(cloudRoot); + roots << cloudRoot; + return roots; +} + +QStringList MeshImporterExporter::textureSearchRootsForEntity(const Ogre::Entity* entity) +{ + if (!entity) + return {}; + const Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh) + return {}; + const Ogre::Any& any = mesh->getUserObjectBindings().getUserAny("qtme.source_path"); + if (!any.has_value()) + return {}; + try { + const std::string sourcePath = Ogre::any_cast(any); + if (sourcePath.empty()) + return {}; + return textureSearchRootsForImportFile(QString::fromStdString(sourcePath)); + } catch (const Ogre::Exception&) { + return {}; + } +} + +void MeshImporterExporter::prepareCloudCachedImport(const QString& localMainFile) +{ + for (const QString& root : textureSearchRootsForImportFile(localMainFile)) + registerImportResourceDirectory(root); } /** @return true if at least one declared material exists in the manager for this group. */ diff --git a/src/MeshImporterExporter.h b/src/MeshImporterExporter.h index 521b60f3e..0c79e3f15 100755 --- a/src/MeshImporterExporter.h +++ b/src/MeshImporterExporter.h @@ -91,6 +91,12 @@ class MeshImporterExporter /// Register cloud cache paths before importing a downloaded project file. static void prepareCloudCachedImport(const QString& localMainFile); + /// Directories to search for sidecar textures after import (file dir + cloud cache root). + static QStringList textureSearchRootsForImportFile(const QString& localPath); + + /// Texture search roots for an entity from its mesh `qtme.source_path` binding. + static QStringList textureSearchRootsForEntity(const Ogre::Entity* entity); + /// Recompile RTSS materials and force SubEntity technique refresh (post-import). static void rebindEntityMaterials(Ogre::Entity* entity, const QStringList& textureSearchRoots = {}); diff --git a/src/TexturePaintController.cpp b/src/TexturePaintController.cpp index f8530fd9f..cb4dcd3a9 100644 --- a/src/TexturePaintController.cpp +++ b/src/TexturePaintController.cpp @@ -159,6 +159,103 @@ class TexturePaintMaskActionCommand : public QUndoCommand bool m_skipFirstRedo = true; }; +Ogre::TexturePtr findTextureAcrossGroups(const std::string& name) +{ + auto texPtr = Ogre::TextureManager::getSingleton().getByName(name); + if (texPtr) + return texPtr; + auto it = Ogre::TextureManager::getSingleton().getResourceIterator(); + while (it.hasMoreElements()) { + const Ogre::ResourcePtr r = it.getNext(); + if (r && r->getName() == name) + return Ogre::static_pointer_cast(r); + } + return {}; +} + +bool copyQImageToPaintBuffer(TexturePaintBuffer& buffer, const QImage& source) +{ + QImage qimg = source; + if (qimg.isNull()) + return false; + if (qimg.format() != QImage::Format_RGBA8888) + qimg = qimg.convertToFormat(QImage::Format_RGBA8888); + const int w = qimg.width(); + const int h = qimg.height(); + if (w <= 0 || h <= 0) + return false; + buffer.resize(w, h); + for (int y = 0; y < h; ++y) { + std::memcpy(buffer.data().data() + static_cast(y) * static_cast(w) * 4u, + qimg.constScanLine(y), + static_cast(w) * 4u); + } + buffer.clearDirty(); + return true; +} + +bool loadPaintBufferFromImageBytes(TexturePaintBuffer& buffer, + const uint8_t* data, + std::size_t size) +{ + QImage qimg; + if (!qimg.loadFromData(data, static_cast(size))) + return false; + return copyQImageToPaintBuffer(buffer, qimg); +} + +bool loadPaintBufferFromDiskPath(TexturePaintBuffer& buffer, const QString& path) +{ + if (path.isEmpty() || !QFileInfo::exists(path)) + return false; + return copyQImageToPaintBuffer(buffer, QImage(path)); +} + +// CPU-side sources first — same order as MaterialEditorQML::previewUrlFromOgreTexture. +// GPU readback (convertToImage / blitToMemory) is unreliable for imported FBX textures. +bool loadPaintBufferFromNonGpuSources(TexturePaintBuffer& buffer, + const Ogre::TexturePtr& texPtr, + const QString& texName) +{ + if (texName.isEmpty()) + return false; + + if (texPtr) { + const QString origin = QString::fromStdString(texPtr->getOrigin()); + if (!origin.isEmpty() && loadPaintBufferFromDiskPath(buffer, origin)) + return true; + + const QString group = QString::fromStdString(texPtr->getGroup()); + if (!group.isEmpty()) { + if (loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + texName)) + return true; + if (!origin.isEmpty() + && loadPaintBufferFromDiskPath(buffer, group + QLatin1Char('/') + origin)) { + return true; + } + } + } + + const std::vector bytes = + EmbeddedTextureCache::retrieve(texName.toStdString()); + if (!bytes.empty() + && loadPaintBufferFromImageBytes(buffer, bytes.data(), bytes.size())) { + return true; + } + + const QString baseName = QFileInfo(texName).fileName(); + if (baseName != texName) { + const std::vector baseBytes = + EmbeddedTextureCache::retrieve(baseName.toStdString()); + if (!baseBytes.empty() + && loadPaintBufferFromImageBytes(buffer, baseBytes.data(), baseBytes.size())) { + return true; + } + } + + return loadPaintBufferFromDiskPath(buffer, texName); +} + } // namespace TexturePaintController* TexturePaintController::instance() @@ -539,9 +636,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) Ogre::TexturePtr originalTex; if (!existingTex.isEmpty()) { try { - originalTex = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); + originalTex = findTextureAcrossGroups(existingTex.toStdString()); } catch (...) {} } m_originalTexture = originalTex; @@ -551,16 +646,22 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) // come from inline FBX embeds (no disk file), legacy on-disk // files, or auto-generated render targets. Each strategy // succeeds for a different source. - // + Ogre::TexturePtr existing = originalTex; + if (!existing) { + try { + existing = findTextureAcrossGroups(existingTex.toStdString()); + } catch (...) {} + } + + // 0. CPU-side: embedded FBX bytes, on-disk origin, resource-group path. + if (loadPaintBufferFromNonGpuSources(m_buffer, existing, existingTex)) { + loadedExisting = true; + loadError.clear(); + } + // 1. TextureManager → convertToImage (works when Ogre keeps // pixels in an Image buffer beside the GPU upload). - Ogre::TexturePtr existing; - try { - existing = Ogre::TextureManager::getSingleton().getByName( - existingTex.toStdString(), - Ogre::ResourceGroupManager::AUTODETECT_RESOURCE_GROUP_NAME); - } catch (...) {} - if (existing) { + if (!loadedExisting && existing) { try { if (!existing->isLoaded()) existing->load(); Ogre::Image img; @@ -581,7 +682,7 @@ bool TexturePaintController::ensurePaintableTexture(int resolution) } catch (...) { loadError = QStringLiteral("convertToImage exception"); } - } else { + } else if (!loadedExisting && !existing) { loadError = QStringLiteral("texture not found in TextureManager"); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index cafc1627a..64e666b98 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -3879,26 +3879,9 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) continue; - QStringList textureRoots; - textureRoots << fileInfo.absolutePath(); - const QString normalized = QDir::fromNativeSeparators(fileInfo.absoluteFilePath()); - const QString marker = QStringLiteral("/cloud/"); - const int cloudIdx = normalized.indexOf(marker); - if (cloudIdx >= 0) { - const QString tail = normalized.mid(cloudIdx + marker.size()); - const int ownerEnd = tail.indexOf(QLatin1Char('/')); - if (ownerEnd > 0) { - const int slugEnd = tail.indexOf(QLatin1Char('/'), ownerEnd + 1); - const QString cloudRoot = slugEnd < 0 - ? normalized - : normalized.left(cloudIdx + marker.size() + slugEnd); - if (!cloudRoot.isEmpty() && cloudRoot != fileInfo.absolutePath()) - textureRoots << cloudRoot; - } - } - auto* entity = static_cast(obj); - MeshImporterExporter::rebindEntityMaterials(entity, textureRoots); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForImportFile(localMainFile)); } SpaceCamera* cam = nullptr; @@ -3914,15 +3897,14 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) cam->frameSelection(); QTimer::singleShot(0, this, [this, localMainFile, entityNamesBefore]() { - const QFileInfo fileInfo(localMainFile); + const QStringList textureRoots = + MeshImporterExporter::textureSearchRootsForImportFile(localMainFile); for (auto* obj : Manager::getSingleton()->getEntities()) { if (!obj || obj->getMovableType() != QLatin1String("Entity")) continue; if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) continue; - QStringList textureRoots; - textureRoots << fileInfo.absolutePath(); MeshImporterExporter::rebindEntityMaterials(static_cast(obj), textureRoots); } @@ -3939,6 +3921,12 @@ void MainWindow::importCloudDownloadedFile(const QString& localMainFile) void MainWindow::importMeshs(const QStringList &_uriList) { + QSet entityNamesBefore; + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (obj && obj->getMovableType() == QLatin1String("Entity")) + entityNamesBefore.insert(QString::fromStdString(obj->getName())); + } + auto txn = SentryReporter::startTransaction("ui.import", "file.import"); QList animOnlySkeletons; try { @@ -3949,6 +3937,39 @@ void MainWindow::importMeshs(const QStringList &_uriList) } SentryReporter::finishTransaction(txn); + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (!obj || obj->getMovableType() != QLatin1String("Entity")) + continue; + if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) + continue; + + auto* entity = static_cast(obj); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForEntity(entity)); + } + + QTimer::singleShot(0, this, [this, entityNamesBefore]() { + for (auto* obj : Manager::getSingleton()->getEntities()) { + if (!obj || obj->getMovableType() != QLatin1String("Entity")) + continue; + if (entityNamesBefore.contains(QString::fromStdString(obj->getName()))) + continue; + + auto* entity = static_cast(obj); + MeshImporterExporter::rebindEntityMaterials( + entity, MeshImporterExporter::textureSearchRootsForEntity(entity)); + } + + if (m_pRoot && m_pRoot->getRenderSystem()) { + try { + m_pRoot->renderOneFrame(); + } catch (...) { + } + } + for (EditorViewport* vp : mDockWidgetList) + vp->getOgreWidget()->update(); + }); + // Handle animation-only files: show a notification and offer an immediate merge // if a compatible entity is already selected. for (const Ogre::SkeletonPtr& skel : animOnlySkeletons) { diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index a70fb5d1e..057686d2b 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.1'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.9.2'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000; From 71a78971786ac1621b3196022e6cfe0db27fd4b3 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 11:40:12 -0400 Subject: [PATCH 16/24] feat(#407): native auto-rig core + CLI rig subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinocchio (Baran & Popović 2007) is LGPL-2.1, which conflicts with the project's statically-linked permissive-distribution stance — so, like #401 (Instant Meshes) and #402 (libigl/TetGen), this is a native from-scratch implementation of the published *algorithm* (skeleton-template embedding), zero new deps. - AutoRig (src/AutoRig.h/.cpp): Ogre-free pure-data core — built-in templates (humanoid 19-bone / biped / quadruped / generic), fitTemplate() maps a template's normalised joint graph into the mesh AABB then recentres flagged joints toward per-height-slab centroids (spine→medial line, limb roots inside the silhouette). rigEntity() builds an Ogre::Skeleton (parent-relative bone positions, setBindingPose), binds via mesh->_notifySkeleton + entity ->_initialise(true) — the _initialise is REQUIRED or the exporters (both gate on entity->hasSkeleton()) silently drop the new rig. - AutoRigController (QML singleton, mirrors SkinWeightsController) for the GUI. - CLI: `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (cmdRig) — import, rig, optionally chain SkinWeights::computeAndApply, export. Registered in run() dispatch + AppLaunchHandler subcommand list. - AutoRig_test.cpp: pure-data unit tests (template well-formedness, AABB containment, vertical ordering, degenerate-input robustness, string/JSON). - Sentry breadcrumb ai.assist.auto_rig. Verified end-to-end: static OBJ -> 19-bone humanoid + skin -> glTF export with 1 skin / 17 joints; FBX export carries the skeleton too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AppLaunchHandler.cpp | 4 +- src/AutoRig.cpp | 384 ++++++++++++++++++++++++++++++++++++++ src/AutoRig.h | 138 ++++++++++++++ src/AutoRigController.cpp | 131 +++++++++++++ src/AutoRigController.h | 54 ++++++ src/AutoRig_test.cpp | 157 ++++++++++++++++ src/CLIPipeline.cpp | 119 ++++++++++++ src/CLIPipeline.h | 5 + src/CMakeLists.txt | 4 + 9 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 src/AutoRig.cpp create mode 100644 src/AutoRig.h create mode 100644 src/AutoRigController.cpp create mode 100644 src/AutoRigController.h create mode 100644 src/AutoRig_test.cpp diff --git a/src/AppLaunchHandler.cpp b/src/AppLaunchHandler.cpp index b38a02926..b9c3e8be8 100644 --- a/src/AppLaunchHandler.cpp +++ b/src/AppLaunchHandler.cpp @@ -26,8 +26,8 @@ bool isCliSubcommand(const QString& arg) QStringLiteral("decimate"), QStringLiteral("atlas"), QStringLiteral("atlas-apply"), QStringLiteral("optimize"), QStringLiteral("bake-vertex-colors"), QStringLiteral("vat"), QStringLiteral("uv"), QStringLiteral("retopo"), - QStringLiteral("skin"), QStringLiteral("morph"), QStringLiteral("nodeanim"), - QStringLiteral("cloud"), + QStringLiteral("skin"), QStringLiteral("rig"), QStringLiteral("morph"), + QStringLiteral("nodeanim"), QStringLiteral("cloud"), }; return kSubcommands.contains(arg); } diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp new file mode 100644 index 000000000..7cdb2abd3 --- /dev/null +++ b/src/AutoRig.cpp @@ -0,0 +1,384 @@ +#include "AutoRig.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// A template joint literal: name, parent index, normalised x/y/z in +// [0,1]^3 (y = up), and whether the refinement step recentres it. +struct TJ { const char* name; int parent; double x, y, z; bool recenter; }; + +// --- Skeleton templates ----------------------------------------------------- +// +// Positions are in a normalised unit box: x in [0,1] left→right, y in +// [0,1] down→up, z in [0,1] back→front. The mesh's actual up axis is +// remapped from +Y at fit time via Options::upAxis. 0.5 is centre. + +// Humanoid (≈ Mixamo-lite): pelvis → spine → chest → neck → head, plus +// symmetric shoulder/arm and hip/leg chains. Limb tips keep their +// proportional position (recenter=false) so they reach to the silhouette. +const TJ kHumanoid[] = { + {"Hips", -1, 0.50, 0.52, 0.50, true}, + {"Spine", 0, 0.50, 0.62, 0.50, true}, + {"Chest", 1, 0.50, 0.72, 0.50, true}, + {"Neck", 2, 0.50, 0.84, 0.50, true}, + {"Head", 3, 0.50, 0.92, 0.50, true}, + // Left arm (model's left = +x). + {"LeftShoulder", 2, 0.60, 0.78, 0.50, true}, + {"LeftArm", 5, 0.70, 0.78, 0.50, false}, + {"LeftForeArm", 6, 0.82, 0.78, 0.50, false}, + {"LeftHand", 7, 0.93, 0.78, 0.50, false}, + // Right arm (-x). + {"RightShoulder",2, 0.40, 0.78, 0.50, true}, + {"RightArm", 9, 0.30, 0.78, 0.50, false}, + {"RightForeArm",10, 0.18, 0.78, 0.50, false}, + {"RightHand", 11, 0.07, 0.78, 0.50, false}, + // Left leg. + {"LeftUpLeg", 0, 0.58, 0.50, 0.50, true}, + {"LeftLeg", 13, 0.58, 0.27, 0.50, false}, + {"LeftFoot", 14, 0.58, 0.04, 0.55, false}, + // Right leg. + {"RightUpLeg", 0, 0.42, 0.50, 0.50, true}, + {"RightLeg", 16, 0.42, 0.27, 0.50, false}, + {"RightFoot", 17, 0.42, 0.04, 0.55, false}, +}; + +// Biped: spine + 2 legs + short arm stubs (simpler/cheaper than humanoid). +const TJ kBiped[] = { + {"Hips", -1, 0.50, 0.52, 0.50, true}, + {"Spine", 0, 0.50, 0.68, 0.50, true}, + {"Head", 1, 0.50, 0.90, 0.50, true}, + {"LeftArm", 1, 0.68, 0.74, 0.50, false}, + {"RightArm", 1, 0.32, 0.74, 0.50, false}, + {"LeftUpLeg", 0, 0.58, 0.50, 0.50, true}, + {"LeftFoot", 5, 0.58, 0.04, 0.55, false}, + {"RightUpLeg", 0, 0.42, 0.50, 0.50, true}, + {"RightFoot", 7, 0.42, 0.04, 0.55, false}, +}; + +// Quadruped: a horizontal spine (front→back along +z), 4 legs, head, tail. +// Body lies low; "up" is still +y. Front of the body = high z. +const TJ kQuadruped[] = { + {"SpineFront", -1, 0.50, 0.55, 0.70, true}, + {"SpineMid", 0, 0.50, 0.55, 0.50, true}, + {"SpineBack", 1, 0.50, 0.55, 0.30, true}, + {"Neck", 0, 0.50, 0.62, 0.82, true}, + {"Head", 3, 0.50, 0.66, 0.95, true}, + {"Tail", 2, 0.50, 0.55, 0.08, false}, + // Front legs (high z). + {"FrontLeftUpLeg", 0, 0.62, 0.45, 0.72, true}, + {"FrontLeftFoot", 6, 0.62, 0.04, 0.72, false}, + {"FrontRightUpLeg", 0, 0.38, 0.45, 0.72, true}, + {"FrontRightFoot", 8, 0.38, 0.04, 0.72, false}, + // Back legs (low z). + {"BackLeftUpLeg", 2, 0.62, 0.45, 0.30, true}, + {"BackLeftFoot", 10, 0.62, 0.04, 0.30, false}, + {"BackRightUpLeg", 2, 0.38, 0.45, 0.30, true}, + {"BackRightFoot", 12, 0.38, 0.04, 0.30, false}, +}; + +// Generic fallback: a 3-joint vertical spine. Always succeeds. +const TJ kGeneric[] = { + {"Root", -1, 0.50, 0.05, 0.50, true}, + {"Spine", 0, 0.50, 0.50, 0.50, true}, + {"Top", 1, 0.50, 0.95, 0.50, true}, +}; + +std::vector toJoints(const TJ* arr, size_t n) +{ + std::vector out; + out.reserve(n); + for (size_t i = 0; i < n; ++i) { + AutoRig::Joint j; + j.name = QString::fromUtf8(arr[i].name); + j.parent = arr[i].parent; + j.pos = {arr[i].x, arr[i].y, arr[i].z}; + j.recenter = arr[i].recenter; + out.push_back(std::move(j)); + } + return out; +} + +} // namespace + +// Out-of-line so the {} default args on the static methods resolve to a +// constructor call (not class-definition-time aggregate init). The member +// initializers in the header supply the actual default values. +AutoRig::Options::Options() = default; + +std::vector AutoRig::templateJoints(Template tmpl) +{ + switch (tmpl) { + case Template::Humanoid: return toJoints(kHumanoid, std::size(kHumanoid)); + case Template::Biped: return toJoints(kBiped, std::size(kBiped)); + case Template::Quadruped: return toJoints(kQuadruped, std::size(kQuadruped)); + case Template::Generic: return toJoints(kGeneric, std::size(kGeneric)); + } + return toJoints(kGeneric, std::size(kGeneric)); +} + +std::vector AutoRig::fitTemplate(const std::vector& tmpl, + const float* verts, + int vertexCount, + const Options& opts, + int* outRecentered) +{ + std::vector placed = tmpl; + if (outRecentered) *outRecentered = 0; + if (!verts || vertexCount <= 0 || tmpl.empty()) return placed; + + // 1. AABB of the vertex cloud. + double mn[3] = { 1e300, 1e300, 1e300}; + double mx[3] = {-1e300, -1e300, -1e300}; + for (int i = 0; i < vertexCount; ++i) { + for (int a = 0; a < 3; ++a) { + const double v = verts[3 * i + a]; + mn[a] = std::min(mn[a], v); + mx[a] = std::max(mx[a], v); + } + } + double ext[3]; + for (int a = 0; a < 3; ++a) ext[a] = std::max(1e-9, mx[a] - mn[a]); + + const int up = std::clamp(opts.upAxis, 0, 2); + // The two in-plane axes (everything that isn't "up"). + const int p0 = (up == 0) ? 1 : 0; + const int p1 = (up == 2) ? 1 : 2; + + // The template's y coordinate is "up"; its x,z are the in-plane axes. + // Map template axis -> world axis so the box orients to the mesh's up. + auto tmplAxisToWorld = [&](int tAxis) { + // tAxis: 0=template-x, 1=template-y(up), 2=template-z + if (tAxis == 1) return up; + return (tAxis == 0) ? p0 : p1; + }; + + // 2. Map each joint's normalised position into the AABB. + for (auto& j : placed) { + std::array world = {0, 0, 0}; + for (int tAxis = 0; tAxis < 3; ++tAxis) { + const int w = tmplAxisToWorld(tAxis); + world[w] = mn[w] + j.pos[tAxis] * ext[w]; + } + j.pos = world; + } + + // 3. Recentre flagged joints toward the mesh's in-plane mass at their + // up-height (pulls the spine onto the medial line, lands limb roots + // inside the silhouette). + const double slab = std::clamp(opts.slabFraction, 1e-3, 0.5) * ext[up]; + int recentered = 0; + for (auto& j : placed) { + if (!j.recenter) continue; + const double y = j.pos[up]; + double sum0 = 0, sum1 = 0; + long long n = 0; + for (int i = 0; i < vertexCount; ++i) { + if (std::abs(static_cast(verts[3 * i + up]) - y) > slab) continue; + sum0 += verts[3 * i + p0]; + sum1 += verts[3 * i + p1]; + ++n; + } + if (n > 0) { + // Blend toward the slab centroid (0.75) but keep a little of the + // template's lateral intent so symmetric joints don't all collapse + // onto the exact centre line. + const double c0 = sum0 / static_cast(n); + const double c1 = sum1 / static_cast(n); + const double kBlend = 0.75; + j.pos[p0] = kBlend * c0 + (1.0 - kBlend) * j.pos[p0]; + j.pos[p1] = kBlend * c1 + (1.0 - kBlend) * j.pos[p1]; + ++recentered; + } + } + if (outRecentered) *outRecentered = recentered; + return placed; +} + +namespace { + +// Tightly read POSITION floats out of a VertexData (same idiom as +// SkinWeights::extractPositions). Appends to `out`. +bool appendPositions(Ogre::VertexData* vd, std::vector& out) +{ + if (!vd) return false; + const auto* posElem = + vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!posElem) return false; + auto vbuf = vd->vertexBufferBinding->getBuffer(posElem->getSource()); + if (!vbuf || vd->vertexCount == 0) return false; + const size_t base0 = out.size(); + out.resize(base0 + static_cast(vd->vertexCount) * 3); + const size_t stride = vbuf->getVertexSize(); + auto* base = static_cast( + vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + for (size_t i = 0; i < vd->vertexCount; ++i) { + float* p = nullptr; + posElem->baseVertexPointerToElement(base + i * stride, &p); + out[base0 + 3 * i + 0] = p[0]; + out[base0 + 3 * i + 1] = p[1]; + out[base0 + 3 * i + 2] = p[2]; + } + vbuf->unlock(); + return true; +} + +} // namespace + +AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) +{ + Report report; + report.templateName = templateToString(opts.tmpl); + + if (!entity || !entity->getMesh()) { + report.error = QStringLiteral("no mesh to rig"); + return report; + } + Ogre::MeshPtr mesh = entity->getMesh(); + report.meshName = QString::fromStdString(mesh->getName()); + + if (mesh->hasSkeleton()) { + report.error = QStringLiteral( + "mesh already has a skeleton — auto-rig only applies to unrigged " + "(static) meshes"); + return report; + } + + // Gather all vertex positions (shared + per-submesh). + std::vector verts; + if (mesh->sharedVertexData) appendPositions(mesh->sharedVertexData, verts); + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (sub && !sub->useSharedVertices && sub->vertexData) + appendPositions(sub->vertexData, verts); + } + const int vcount = static_cast(verts.size() / 3); + if (vcount == 0) { + report.error = QStringLiteral("mesh has no readable vertex positions"); + return report; + } + report.verticesSampled = vcount; + + // Fit the template. + int recentered = 0; + const std::vector tmpl = templateJoints(opts.tmpl); + const std::vector placed = + fitTemplate(tmpl, verts.data(), vcount, opts, &recentered); + report.jointsRecentered = recentered; + + // Build the Ogre skeleton. Bone POSITIONS are parent-relative in Ogre, + // so each child's setPosition is its world pos minus its parent's world + // pos. createBone(name, handle) — handle == index. + auto& skelMgr = Ogre::SkeletonManager::getSingleton(); + const std::string skelName = mesh->getName() + "_autorig"; + if (skelMgr.resourceExists(skelName)) + skelMgr.remove(skelName); + Ogre::SkeletonPtr skel; + try { + skel = skelMgr.create( + skelName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + std::vector bones(placed.size(), nullptr); + for (size_t i = 0; i < placed.size(); ++i) + bones[i] = skel->createBone(placed[i].name.toStdString(), + static_cast(i)); + for (size_t i = 0; i < placed.size(); ++i) { + const Joint& j = placed[i]; + Ogre::Vector3 local( + static_cast(j.pos[0]), + static_cast(j.pos[1]), + static_cast(j.pos[2])); + if (j.parent >= 0 && static_cast(j.parent) < placed.size()) { + bones[j.parent]->addChild(bones[i]); + const Joint& pj = placed[j.parent]; + local -= Ogre::Vector3( + static_cast(pj.pos[0]), + static_cast(pj.pos[1]), + static_cast(pj.pos[2])); + } + bones[i]->setPosition(local); + bones[i]->setOrientation(Ogre::Quaternion::IDENTITY); + } + skel->setBindingPose(); + + // Bind the skeleton to the mesh, then force the entity to + // re-initialise so it acquires a SkeletonInstance. Without the + // _initialise(true), the already-created Ogre::Entity keeps + // hasSkeleton()==false and BOTH exporters (FBXExporter and the + // Assimp glTF/FBX path gate on entity->hasSkeleton()) would drop + // the new rig — the skeleton would exist on the mesh but never + // reach the wire. (Same refresh EditableMesh / EditModeController + // do after mutating an entity's mesh.) + mesh->_notifySkeleton(skel); + entity->_initialise(true); + report.skeletonName = QString::fromStdString(skelName); + report.boneCount = static_cast(placed.size()); + report.applied = true; + } catch (const Ogre::Exception& e) { + report.error = QStringLiteral("Ogre error building skeleton: %1") + .arg(QString::fromStdString(e.getFullDescription())); + if (skel && skelMgr.resourceExists(skelName)) skelMgr.remove(skelName); + report.applied = false; + } + return report; +} + +QString AutoRig::templateToString(Template t) +{ + switch (t) { + case Template::Humanoid: return QStringLiteral("humanoid"); + case Template::Biped: return QStringLiteral("biped"); + case Template::Quadruped: return QStringLiteral("quadruped"); + case Template::Generic: return QStringLiteral("generic"); + } + return QStringLiteral("generic"); +} + +AutoRig::Template AutoRig::templateFromString(const QString& s) +{ + const QString l = s.trimmed().toLower(); + if (l == "humanoid") return Template::Humanoid; + if (l == "biped") return Template::Biped; + if (l == "quadruped" || l == "quad") return Template::Quadruped; + if (l == "generic") return Template::Generic; + return Template::Humanoid; // default +} + +QJsonObject AutoRig::reportToJson(const Report& r) +{ + QJsonObject o; + o["applied"] = r.applied; + o["meshName"] = r.meshName; + o["skeletonName"] = r.skeletonName; + o["template"] = r.templateName; + o["boneCount"] = r.boneCount; + o["verticesSampled"] = r.verticesSampled; + o["jointsRecentered"] = r.jointsRecentered; + if (!r.error.isEmpty()) o["error"] = r.error; + return o; +} + +QString AutoRig::reportToText(const Report& r) +{ + if (!r.applied) + return QStringLiteral("Auto-rig failed: %1\n") + .arg(r.error.isEmpty() ? QStringLiteral("unknown error") : r.error); + return QStringLiteral( + "Auto-rigged %1 with the '%2' template.\n" + " bones: %3\n vertices sampled: %4\n joints recentered: %5\n") + .arg(r.meshName, r.templateName) + .arg(r.boneCount).arg(r.verticesSampled).arg(r.jointsRecentered); +} diff --git a/src/AutoRig.h b/src/AutoRig.h new file mode 100644 index 000000000..c892b6fc0 --- /dev/null +++ b/src/AutoRig.h @@ -0,0 +1,138 @@ +#ifndef AUTO_RIG_H +#define AUTO_RIG_H + +#include +#include +#include +#include +#include + +namespace Ogre { + class Entity; + class Mesh; + class Skeleton; +} + +// Native automatic rigging — predicts a skeleton for an unrigged mesh +// (issue #407, epic #397). +// +// The issue proposes wrapping **Pinocchio** (Baran & Popović, SIGGRAPH +// 2007). Pinocchio's *core library* is **LGPL-2.1-or-later** (only its +// demo CLI is MIT). Statically vendoring an LGPL library imposes +// relink / object-file obligations that conflict with this project's +// statically-linked, permissively-redistributed binaries (Homebrew / +// Snap / WinGet / Docker) and its permissive-license stance — the same +// reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs +// GPL TetGen) shipped native heuristics instead. Pinocchio's *algorithm* +// (embed a skeleton template into the mesh interior via a distance field) +// is published and unencumbered; only its code is LGPL, so this is a +// from-scratch native implementation of the approach with **zero new +// dependencies**. +// +// Algorithm (heuristic embedding): +// 1. Read mesh vertices → axis-aligned bounding box (AABB) + the up +// axis (default +Y). +// 2. Each skeleton template is a proportional joint graph expressed in +// a normalised unit box [0,1]^3 (origin = min corner, y = up). +// Map every joint's normalised position into the mesh AABB. +// 3. Refine: for each joint, recentre it toward the mesh's mass at +// that height by snapping its in-plane (non-up) coordinates to the +// centroid of the vertices in a thin slab around the joint's up +// coordinate. This pulls the spine onto the body's medial line and +// lands limb roots inside the silhouette instead of on the AABB +// shell. Joints whose slab is empty keep their AABB-proportional +// position. +// +// The result is an Ogre::Skeleton in bind pose, ready to bind to the +// mesh and (optionally) feed into #402 SkinWeights for a one-click +// rig + skin. **Quality limits** (documented per the issue): like +// Pinocchio, this works best on roughly upright, single-component, +// manifold, T/A-pose meshes whose up axis is +Y. It is a heuristic — it +// does not detect limbs from topology, so exotic proportions or non- +// upright poses can misplace joints. + +class AutoRig { +public: + // Built-in skeleton templates. + enum class Template { + Humanoid, // pelvis/spine/head + 2 arms + 2 legs (≈ Mixamo-lite) + Biped, // simplified humanoid: spine + 2 legs + stub arms + Quadruped, // spine + 4 legs + head + tail + Generic // a simple 3-joint spine chain (fallback for anything) + }; + + // One joint of a template / placed skeleton. + struct Joint { + QString name; + int parent = -1; // index into the joint list (-1 = root) + // For a TEMPLATE: normalised position in the unit box [0,1]^3. + // For a PLACED skeleton: world-space position in mesh local space. + std::array pos = {0, 0, 0}; + // When true, the refinement step recentres this joint's in-plane + // coords toward the mesh slab centroid (spine/limb-root joints). + // When false, the joint keeps its proportional position (e.g. the + // tip of a limb, which should reach toward the AABB edge). + bool recenter = true; + }; + + struct Options { + // NOTE: declared (not defined) here so `Options{}` default args on the + // member functions below don't force aggregate init of this nested + // struct while the enclosing AutoRig class is still incomplete (which + // GCC rejects: "default member initializer for 'tmpl' needed ..."). + Options(); + Template tmpl = Template::Humanoid; + // Up axis: 0=X, 1=Y, 2=Z. Default +Y (the in-app / glTF / FBX + // convention after import normalisation). + int upAxis = 1; + // Slab half-thickness for the centroid recentre, as a fraction of + // the mesh extent along the up axis. Larger = smoother spine, + // less responsive to local mass. Range (0, 0.5]; default 0.06. + double slabFraction = 0.06; + }; + + struct Report { + QString meshName; + QString skeletonName; + QString templateName; + int boneCount = 0; + int verticesSampled = 0; + int jointsRecentered = 0; + bool applied = false; + QString error; + }; + + // --- Ogre-facing entry point (CLI / MCP / GUI) ----------------------- + + // Generate a skeleton from `opts.tmpl`, fit it to `entity`'s mesh, + // bind it (mesh->_notifySkeleton + setBindingPose), and return a + // report. The entity must be a static (skeleton-less) mesh — an + // already-rigged mesh returns applied=false with an error (unless + // it has no usable geometry). After this returns applied=true, the + // caller may chain SkinWeights::computeAndApply(entity) for weights. + static Report rigEntity(Ogre::Entity* entity, const Options& opts = {}); + + // --- Pure-data core (unit-testable, no Ogre) ------------------------- + + // The proportional joint graph for a template (positions in [0,1]^3). + static std::vector templateJoints(Template tmpl); + + // Fit `templateJoints` to a vertex cloud: map into the AABB, then + // recentre toward per-slab centroids. `vertexPositions` is tightly + // packed xyz (3 floats per vertex). Returns placed joints in the + // same order/parenting as the template, positions now in mesh local + // space. `outRecentered` (optional) receives the count of joints + // that were recentred against a non-empty slab. + static std::vector fitTemplate(const std::vector& tmpl, + const float* vertexPositions, + int vertexCount, + const Options& opts, + int* outRecentered = nullptr); + + static QString templateToString(Template t); + static Template templateFromString(const QString& s); + static QJsonObject reportToJson(const Report& r); + static QString reportToText(const Report& r); +}; + +#endif // AUTO_RIG_H diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp new file mode 100644 index 000000000..739d90d29 --- /dev/null +++ b/src/AutoRigController.cpp @@ -0,0 +1,131 @@ +#include "AutoRigController.h" +#include "AutoRig.h" +#include "SkinWeights.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include + +AutoRigController* AutoRigController::m_pSingleton = nullptr; + +AutoRigController* AutoRigController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new AutoRigController(); + return m_pSingleton; +} + +AutoRigController* AutoRigController::qmlInstance(QQmlEngine* engine, QJSEngine*) +{ + Q_UNUSED(engine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void AutoRigController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +AutoRigController::AutoRigController() : QObject(nullptr) +{ + connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, + this, &AutoRigController::selectionChanged); +} + +bool AutoRigController::hasRiggableSelection() const +{ + auto* sel = SelectionSet::getSingleton(); + if (!sel) return false; + const auto entities = sel->getResolvedEntities(); + if (entities.isEmpty()) return false; + Ogre::Entity* first = entities.first(); + if (!first || !first->getMesh()) return false; + // Riggable == static (no skeleton yet). An already-skinned mesh is + // intentionally excluded (re-rigging would wipe its existing rig). + return first->getMesh()->getSkeleton() == nullptr; +} + +QVariantMap AutoRigController::autoRigSelected(const QString& templateName, + bool alsoSkin) +{ + QVariantMap result; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Auto-rig requested (%1%2)") + .arg(templateName, alsoSkin ? QStringLiteral(", +skin") : QString())); + + auto* sel = SelectionSet::getSingleton(); + const auto entities = sel ? sel->getResolvedEntities() : QList{}; + if (entities.isEmpty()) { + const auto msg = QStringLiteral("No mesh selected."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + Ogre::Entity* entity = entities.first(); + if (!entity || !entity->getMesh()) { + const auto msg = QStringLiteral("Selected entity is no longer valid."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + + AutoRig::Options opts; + opts.tmpl = AutoRig::templateFromString(templateName); + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("UI auto-rig entity=%1 template=%2") + .arg(QString::fromStdString(entity->getName()), + AutoRig::templateToString(opts.tmpl))); + + m_busy = true; + emit busyChanged(); + + AutoRig::Report report; + bool skinned = false; + try { + report = AutoRig::rigEntity(entity, opts); + if (report.applied && alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) + report.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } + } catch (const Ogre::Exception& e) { + m_busy = false; + emit busyChanged(); + const auto msg = QString::fromStdString(e.getFullDescription()); + emit error(QStringLiteral("Ogre error: %1").arg(msg)); + result["applied"] = false; + result["error"] = msg; + return result; + } + + m_busy = false; + emit busyChanged(); + emit selectionChanged(); // skeleton state changed → refresh button bindings + + result["applied"] = report.applied; + result["meshName"] = report.meshName; + result["skeletonName"] = report.skeletonName; + result["template"] = report.templateName; + result["boneCount"] = report.boneCount; + result["verticesSampled"] = report.verticesSampled; + result["jointsRecentered"] = report.jointsRecentered; + result["skinned"] = skinned; + if (!report.error.isEmpty()) result["error"] = report.error; + + if (report.applied) emit rigged(result); + else emit error(report.error.isEmpty() + ? QStringLiteral("Auto-rig failed") : report.error); + + return result; +} diff --git a/src/AutoRigController.h b/src/AutoRigController.h new file mode 100644 index 000000000..7ddc8fee0 --- /dev/null +++ b/src/AutoRigController.h @@ -0,0 +1,54 @@ +#ifndef AUTO_RIG_CONTROLLER_H +#define AUTO_RIG_CONTROLLER_H + +#include +#include +#include + +// QML-facing singleton for native auto-rigging (issue #407). +// Wraps `AutoRig::rigEntity` (+ optional `SkinWeights::computeAndApply`) +// and exposes selection state so the Animation-Mode button can disable +// itself when the selection isn't a riggable static mesh. +class AutoRigController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + // True when the selected entity is a STATIC (skeleton-less) mesh — + // the only thing auto-rig can sensibly act on. Already-rigged meshes + // and empty selections disable the button. + Q_PROPERTY(bool hasRiggableSelection READ hasRiggableSelection NOTIFY selectionChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + +public: + static AutoRigController* instance(); + static AutoRigController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + bool hasRiggableSelection() const; + bool busy() const { return m_busy; } + + /// Auto-rig the first resolved selected entity with `templateName` + /// (humanoid / biped / quadruped / generic). When `alsoSkin` is true, + /// chains SkinWeights::computeAndApply so the mesh deforms immediately. + /// Returns a QVariantMap mirroring AutoRig::Report (+ a `skinned` bool). + /// Emits `rigged(report)` on success or `error(msg)` on failure. + Q_INVOKABLE QVariantMap autoRigSelected(const QString& templateName, + bool alsoSkin); + +signals: + void selectionChanged(); + void busyChanged(); + void rigged(const QVariantMap& report); + void error(const QString& message); + +private: + AutoRigController(); + ~AutoRigController() override = default; + + static AutoRigController* m_pSingleton; + bool m_busy = false; +}; + +#endif // AUTO_RIG_CONTROLLER_H diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp new file mode 100644 index 000000000..5edfa3957 --- /dev/null +++ b/src/AutoRig_test.cpp @@ -0,0 +1,157 @@ +// Unit tests for AutoRig (#407). The pure-data core (templateJoints / +// fitTemplate) needs no Ogre/GL context, so these run everywhere — unlike +// rigEntity() which needs a loaded mesh (covered by the CLI coverage test +// under Xvfb on CI). + +#include + +#include +#include + +#include "AutoRig.h" + +namespace { + +// Build a synthetic upright "humanoid-ish" point cloud: 2 units tall (y), +// ~1 wide at the shoulders, narrow elsewhere, centred on x/z=0. +std::vector uprightCloud() +{ + std::vector v; + for (int i = 0; i < 2000; ++i) { + const float y = (static_cast(i) / 2000.0f) * 2.0f; + const float w = (y > 1.4f && y < 1.7f) ? 0.9f : 0.35f; // shoulders bulge + for (int s = -1; s <= 1; s += 2) { + v.push_back(s * w * 0.5f); + v.push_back(y); + v.push_back(0.0f); + } + } + return v; +} + +} // namespace + +TEST(AutoRigCore, TemplatesAreNonEmptyAndWellParented) +{ + for (auto t : {AutoRig::Template::Humanoid, AutoRig::Template::Biped, + AutoRig::Template::Quadruped, AutoRig::Template::Generic}) { + const auto js = AutoRig::templateJoints(t); + ASSERT_FALSE(js.empty()); + // Exactly one root; every non-root parent index is a valid earlier joint. + int roots = 0; + for (size_t i = 0; i < js.size(); ++i) { + if (js[i].parent < 0) { ++roots; continue; } + EXPECT_GE(js[i].parent, 0); + EXPECT_LT(static_cast(js[i].parent), js.size()); + EXPECT_LT(static_cast(js[i].parent), i) + << "parent must precede child for single-pass bone build"; + // Normalised template coords stay in [0,1]. + for (int a = 0; a < 3; ++a) { + EXPECT_GE(js[i].pos[a], 0.0); + EXPECT_LE(js[i].pos[a], 1.0); + } + } + EXPECT_EQ(roots, 1) << "template must have exactly one root"; + } +} + +TEST(AutoRigCore, HumanoidHasExpectedBoneCount) +{ + EXPECT_EQ(AutoRig::templateJoints(AutoRig::Template::Humanoid).size(), 19u); + EXPECT_EQ(AutoRig::templateJoints(AutoRig::Template::Generic).size(), 3u); +} + +TEST(AutoRigCore, FitPlacesAllJointsInsideAABB) +{ + const auto cloud = uprightCloud(); + const int n = static_cast(cloud.size() / 3); + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + + AutoRig::Options o; + o.tmpl = AutoRig::Template::Humanoid; + o.upAxis = 1; + int recentered = 0; + const auto placed = AutoRig::fitTemplate(tmpl, cloud.data(), n, o, &recentered); + + ASSERT_EQ(placed.size(), tmpl.size()); + EXPECT_GT(recentered, 0) << "spine/limb-root joints should recentre on a real cloud"; + + // Cloud AABB: x in [-0.45, 0.45], y in [0, 2], z == 0. + for (const auto& j : placed) { + EXPECT_GE(j.pos[1], -1e-3); + EXPECT_LE(j.pos[1], 2.0 + 1e-3) << j.name.toStdString() << " y out of AABB"; + EXPECT_GE(j.pos[0], -0.45 - 1e-3); + EXPECT_LE(j.pos[0], 0.45 + 1e-3) << j.name.toStdString() << " x out of AABB"; + } +} + +TEST(AutoRigCore, FitRespectsVerticalOrdering) +{ + const auto cloud = uprightCloud(); + const int n = static_cast(cloud.size() / 3); + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options o; + const auto placed = AutoRig::fitTemplate(tmpl, cloud.data(), n, o, nullptr); + + auto yOf = [&](const QString& name) -> double { + for (const auto& j : placed) if (j.name == name) return j.pos[1]; + return -1e9; + }; + // Head above hips above feet. + EXPECT_GT(yOf("Head"), yOf("Hips")); + EXPECT_GT(yOf("Hips"), yOf("LeftFoot")); + EXPECT_GT(yOf("Hips"), yOf("RightFoot")); + // Symmetric feet stay on opposite sides of centre (x sign preserved). + EXPECT_GT(yOf("Head"), 1.5); // head lands in the upper portion +} + +TEST(AutoRigCore, FitIsRobustToDegenerateInput) +{ + const auto tmpl = AutoRig::templateJoints(AutoRig::Template::Generic); + AutoRig::Options o; + int rc = -1; + // Null / zero-count → returns the template unchanged, no crash, rc=0. + const auto p0 = AutoRig::fitTemplate(tmpl, nullptr, 0, o, &rc); + EXPECT_EQ(p0.size(), tmpl.size()); + EXPECT_EQ(rc, 0); + + // Single degenerate vertex (all same point) → no division blow-up. + std::vector one = {0.5f, 0.5f, 0.5f}; + const auto p1 = AutoRig::fitTemplate(tmpl, one.data(), 1, o, &rc); + EXPECT_EQ(p1.size(), tmpl.size()); + for (const auto& j : p1) + for (int a = 0; a < 3; ++a) + EXPECT_TRUE(std::isfinite(j.pos[a])); +} + +TEST(AutoRigCore, TemplateStringRoundTrip) +{ + using T = AutoRig::Template; + for (auto t : {T::Humanoid, T::Biped, T::Quadruped, T::Generic}) + EXPECT_EQ(AutoRig::templateFromString(AutoRig::templateToString(t)), t); + // Unknown → humanoid default; alias "quad". + EXPECT_EQ(AutoRig::templateFromString("nonsense"), T::Humanoid); + EXPECT_EQ(AutoRig::templateFromString("quad"), T::Quadruped); + EXPECT_EQ(AutoRig::templateFromString("HUMANOID"), T::Humanoid); +} + +TEST(AutoRigCore, ReportSerialization) +{ + AutoRig::Report r; + r.applied = true; + r.meshName = "robot"; + r.templateName = "humanoid"; + r.boneCount = 19; + r.verticesSampled = 1234; + r.jointsRecentered = 11; + const auto j = AutoRig::reportToJson(r); + EXPECT_TRUE(j["applied"].toBool()); + EXPECT_EQ(j["boneCount"].toInt(), 19); + EXPECT_EQ(j["template"].toString(), "humanoid"); + EXPECT_FALSE(AutoRig::reportToText(r).isEmpty()); + + AutoRig::Report fail; + fail.applied = false; + fail.error = "boom"; + EXPECT_TRUE(AutoRig::reportToText(fail).contains("boom")); +} diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index c1511bfaa..3517bb633 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -23,6 +23,7 @@ #include "UvUnwrap.h" #include "QuadRetopo.h" #include "SkinWeights.h" +#include "AutoRig.h" #include "MeshDecimator.h" #include "EditableMesh.h" #include "TexturePaintBuffer.h" @@ -1499,6 +1500,7 @@ int CLIPipeline::run(int argc, char* argv[]) else if (cmd == "uv") rc = cmdUv(argc, argv); else if (cmd == "retopo") rc = cmdRetopo(argc, argv); else if (cmd == "skin") rc = cmdSkin(argc, argv); + else if (cmd == "rig") rc = cmdRig(argc, argv); else if (cmd == "morph") rc = cmdMorph(argc, argv); else if (cmd == "nodeanim") rc = cmdNodeAnim(argc, argv); else if (cmd == "cloud") rc = CloudCLIPipeline::run(argc, argv); @@ -8164,6 +8166,123 @@ int CLIPipeline::cmdSkin(int argc, char* argv[]) return 0; } +int CLIPipeline::cmdRig(int argc, char* argv[]) +{ + // Parse: rig [--skeleton humanoid|biped|quadruped|generic] + // [--skin] [--up-axis x|y|z] -o [--json] + QString inputPath, outputPath, templateName = QStringLiteral("humanoid"); + bool jsonOutput = false; + bool alsoSkin = false; + int upAxis = 1; // +Y default + + for (int i = 1; i < argc; ++i) { + const QString arg = QString::fromLocal8Bit(argv[i]); + if (arg == "rig" || arg == "--cli") continue; + if (arg == "--json") { jsonOutput = true; continue; } + if (arg == "--skin") { alsoSkin = true; continue; } + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + outputPath = QString::fromLocal8Bit(argv[++i]); continue; + } + if ((arg == "--skeleton" || arg == "--template") && i + 1 < argc) { + templateName = QString::fromLocal8Bit(argv[++i]); continue; + } + if (arg == "--up-axis" && i + 1 < argc) { + const QString a = QString::fromLocal8Bit(argv[++i]).toLower(); + if (a == "x") upAxis = 0; + else if (a == "y") upAxis = 1; + else if (a == "z") upAxis = 2; + else { err() << "Error: --up-axis must be x, y, or z." << Qt::endl; return 2; } + continue; + } + if (!arg.startsWith("-") && inputPath.isEmpty()) { + inputPath = arg; continue; + } + } + + if (inputPath.isEmpty()) { + err() << "Error: No input file specified." << Qt::endl; + err() << "Usage: qtmesh rig [--skeleton humanoid|biped|quadruped|generic] " + "[--skin] [--up-axis x|y|z] -o [--json]" << Qt::endl; + return 2; + } + if (outputPath.isEmpty()) { + err() << "Error: -o required." << Qt::endl; + return 2; + } + + QFileInfo fi(inputPath); + if (!fi.exists()) { + err() << "Error: file not found: " << inputPath << Qt::endl; return 1; + } + if (!initOgreHeadless()) return 1; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QString("rig .%1 template=%2 skin=%3") + .arg(fi.suffix(), templateName).arg(alsoSkin)); + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QString("Importing %1").arg(fi.absoluteFilePath())); + + MeshImporterExporter::importer({fi.absoluteFilePath()}); + QList meshEntities; + for (Ogre::Entity* e : Manager::getSingleton()->getEntities()) { + if (e && e->getMovableType() == "Entity") + meshEntities.push_back(e); + } + if (meshEntities.isEmpty()) { + err() << "Error: failed to load " << inputPath << Qt::endl; return 1; + } + if (meshEntities.size() > 1) { + err() << "Error: " << inputPath + << " contains multiple mesh entities. `qtmesh rig` supports one " + "entity per file." << Qt::endl; + return 1; + } + Ogre::Entity* entity = meshEntities.first(); + + AutoRig::Options opts; + opts.tmpl = AutoRig::templateFromString(templateName); + opts.upAxis = upAxis; + + AutoRig::Report report = AutoRig::rigEntity(entity, opts); + if (!report.applied) { + err() << "Error: auto-rig failed — " << report.error << Qt::endl; + return 1; + } + + // Optionally chain skin weights so the exported asset deforms. + bool skinned = false; + if (alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) { + err() << "Error: rigged, but skinning failed — " << sw.error << Qt::endl; + return 1; + } + } + + auto* node = entity->getParentSceneNode(); + const QString fmt = formatForExtension(outputPath); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QString("Exporting %1").arg(QFileInfo(outputPath).absoluteFilePath())); + if (MeshImporterExporter::exporter(node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { + err() << "Error: export failed." << Qt::endl; + return 1; + } + + if (jsonOutput) { + QJsonObject j = AutoRig::reportToJson(report); + j["skinned"] = skinned; + cliWrite(QString::fromUtf8( + QJsonDocument(j).toJson(QJsonDocument::Indented)) + "\n"); + } else { + cliWrite(AutoRig::reportToText(report) + + (alsoSkin ? QString(" skinned: %1\n").arg(skinned ? "yes" : "no") + : QString()) + + QString("Wrote: %1\n").arg(QFileInfo(outputPath).fileName())); + } + return 0; +} + int CLIPipeline::cmdMorph(int argc, char* argv[]) { // Parse: morph --list [--json] diff --git a/src/CLIPipeline.h b/src/CLIPipeline.h index 4a01e287a..7a2eee40c 100644 --- a/src/CLIPipeline.h +++ b/src/CLIPipeline.h @@ -207,6 +207,11 @@ class CLIPipeline { /// distance heuristic. Issue #402. static int cmdSkin(int argc, char* argv[]); + /// Native auto-rig: embed a skeleton template (humanoid / biped / + /// quadruped / generic) into an unrigged mesh, optionally chain + /// skin weights (--skin), and export. Issue #407. + static int cmdRig(int argc, char* argv[]); + /// List the morph targets / blend shapes on a mesh file. Slice A1 /// surfaces a `--list` mode only; subsequent slices add `--set`, /// `--add`, `--delete` once the in-memory authoring path lands. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3d5646a0a..835eea086 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -90,6 +90,8 @@ QuadRetopo.cpp QuadRetopoController.cpp SkinWeights.cpp SkinWeightsController.cpp +AutoRig.cpp +AutoRigController.cpp MeshDepthRenderer.cpp MultiViewTextureBaker.cpp TextureChannelPacker.cpp @@ -229,6 +231,8 @@ QuadRetopo.h QuadRetopoController.h SkinWeights.h SkinWeightsController.h +AutoRig.h +AutoRigController.h MeshDepthRenderer.h MultiViewTextureBaker.h TextureChannelPacker.h From d1594bf3bfaef889629c4e13cd18ffce4349cf80 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 11:49:47 -0400 Subject: [PATCH 17/24] feat(#407): MCP auto_rig tool + GUI Auto-Rig + rig CLI tests + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP: `auto_rig` { template, skin?, up_axis?, output_path? } (MCPServer::toolAutoRig) — rigs the selected static mesh, optional skin chain + optional re-export. Registered + advertised. Breadcrumb ai.assist.auto_rig. - GUI: AutoRigDialog.qml (template + up-axis pickers, "also skin" checkbox) driven by AutoRigController; new "Rigging" CollapsibleSection in Animation Mode → Mode Tools, gated on AutoRigController.hasRiggableSelection (a static mesh — already-rigged meshes show "Skinning" instead). Lazy-loaded Loader + openAutoRigDialog(), registered in qml_resources.qrc. - Tests: CLIPipeline_cmdrig_coverage_test.cpp (arg-validation + file-missing branches need no GL; success path skips gracefully without Xvfb). - CLAUDE.md: CLI examples (skin + rig), recognized-subcommand list, and a full AutoRig architecture entry (incl. the LGPL→native rationale, the _initialise(true) export gotcha, and documented quality limits). App + UnitTests build clean on macOS arm64. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 6 +- qml/AutoRigDialog.qml | 287 +++++++++++++++++++++++ qml/PropertiesPanel.qml | 101 ++++++++ src/CLIPipeline_cmdrig_coverage_test.cpp | 138 +++++++++++ src/MCPServer.cpp | 115 +++++++++ src/MCPServer.h | 3 + src/qml_resources.qrc | 1 + 7 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 qml/AutoRigDialog.qml create mode 100644 src/CLIPipeline_cmdrig_coverage_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 501739cf3..64f0a9637 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,9 @@ qtmesh uv model.fbx --info # report current UV channels + UV qtmesh uv model.fbx --info --json # same, as JSON qtmesh uv model.fbx --unwrap -o unwrapped.glb # xatlas auto-UV unwrap (#400). Non-overlapping UVs into UV0. qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb # write into UV1 (lightmap workflow) +qtmesh skin model.fbx --max-influences 4 --falloff 4 -o skinned.fbx # auto skin weights (inverse-distance) for a mesh+skeleton (#402) +qtmesh rig model.obj --skeleton humanoid -o rigged.fbx # native auto-rig: embed a skeleton template into an unrigged mesh (#407) +qtmesh rig model.obj --skeleton humanoid --skin -o rigged.fbx # one-click rig + skin (chains #402); templates: humanoid|biped|quadruped|generic; --up-axis x|y|z (default y) qtmesh cloud login # device flow (prints URL + code); stores session locally qtmesh cloud login --api-key # direct API-key login (CI) qtmesh cloud logout # revoke + clear saved session @@ -117,7 +120,7 @@ qtmesh cloud upload model.fbx [--name Hero] [--include "*.png,*.fbx"] [--exclude qtmesh cloud delete # delete a cloud project ``` -CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`, `cloud`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. +CLI mode is activated by: (1) invoking via the `qtmesh` symlink, (2) passing `--cli`, or (3) using a recognized subcommand (`info`, `fix`, `convert`, `anim`, `validate`, `lod`, `pose`, `turntable`, `isometric`, `scan`, `material`, `pack-textures`, `normal-from-height`, `atlas`, `atlas-apply`, `memory`, `analyze`, `vertex-cache`, `decimate`, `optimize`, `uv`, `retopo`, `skin`, `rig`, `cloud`) as the first argument. Use `--verbose` to see Ogre/engine debug output. Use `--no-telemetry` to permanently opt out of anonymous usage data collection. If Xcode SDK is updated, clear CMake cache (`rm build_local/CMakeCache.txt`) and reconfigure. @@ -273,6 +276,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Real-ESRGAN texture upscaling** (`src/TextureUpscaler.h/cpp` + `AIAssistManager`, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. `TextureUpscaler` is the Ogre-free core (reuses `PbrMapSynth::toNCHW`/`nchwToRgb`): a **scale-aware** overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). `AIAssistManager::upscaleTexture(srcPath, scale, overwrite)` extends the per-model `Map` enum with `UpscaleX2`/`UpscaleX4`, downloads the model on first use (same HF repo), runs, caches `_upscaled_x{2,4}.png` next to the source, and emits `upscaleStarted/Completed/Error`. The Material Editor path is worker-threaded and reports state via `upscaleDownloading` (first-run model fetch) / `upscaleProgress(done,total)` (per tile) / `upscaleCompleted`/`upscaleError`; `cancelUpscale()` flips a shared atomic that the tiling loop's `ProgressFn` checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. **Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, [xinntao](https://github.com/xinntao/Real-ESRGAN))** — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via `scripts/export-realesrgan-onnx.py` (one-time, offline, NOT shipped). Surfaced via **CLI `qtmesh material --texture --upscale {2|4} [-o ]`** (`CLIPipeline::cmdMaterialUpscale`), the MCP `upscale_texture` tool, and **"Upscale 2× / 4×" buttons** in the Material Editor's Texture Properties panel. Sentry breadcrumb category `ai.assist.upscale`. ONNX intra-op threads are set to `hardware_concurrency-1` (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — `PbrMapSynth` stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded. - **LLM-assisted material from a description** (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → `MaterialEditorQML::generateMaterialFromPrompt` → `LLMManager::generateMaterial`); #406 adds the missing **CLI + MCP parity** by reusing that exact path headlessly. The shared core `CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)` resolves a GGUF model (the `--model`/`model` override, else last-used / first available via `LLMManager::scanForModels`+`availableModels`), drives `LLMManager::generateMaterial` synchronously through two `QEventLoop`s (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the `material ` header, parses the script via `MaterialManager::parseScript`, `compile()`s, honors a `pbr_workflow` tag through `RTShaderHelper::applyPbrIfTagged`, and binds the material to every submesh of the entity. The **CLI** `qtmesh material --describe "" [--model ] [-o out]` (`CLIPipeline::cmdMaterialDescribe`) imports → applies → re-exports; the **MCP** `describe_material` tool (`MCPServer::toolDescribeMaterial`, args `{prompt, mesh?, model?, output_path?}`) applies to the named/selected entity in-session and optionally re-exports when `output_path` is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — `LLMManager.cpp` always compiles, so no `#ifdef ENABLE_LOCAL_LLM` guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category `ai.assist.describe_material`. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer. - **SkinWeights** (`src/SkinWeights.h/cpp`, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights. The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is **GPL/copyleft**. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with **zero new dependencies**: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply `1/dist^falloff` weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (`maxInfluenceDistance` × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional `skipUnweightedBones` filters Mixamo helper bones. `replaceExisting=false` enables a merge mode for "fill in missing weights" workflows. Surfaced via `qtmesh skin --max-influences N --falloff F -o out`, MCP `compute_skin_weights`, and the **Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button** (`qml/SkinWeightsDialog.qml`, driven by `SkinWeightsController` singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to `hasSkinnedSelection` so it disables on static (skeleton-less) meshes. The GUI path runs through `ComputeSkinWeightsCommand` (`src/commands/`) so the auto-skin is **undoable** (Ctrl+Z): the command snapshots every submesh's `VertexBoneAssignmentList` (+ the mesh-level shared list) before the first `redo`, runs `computeAndApply`, and on `undo` restores the snapshot and calls `_compileBoneAssignments` to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category `ai.assist.skin_weights`. A future slice can plug libigl BBW in behind `-DENABLE_LIBIGL_BBW` for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip. +- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. - **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/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml new file mode 100644 index 000000000..e3843a6e5 --- /dev/null +++ b/qml/AutoRigDialog.qml @@ -0,0 +1,287 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Window +import MaterialEditorQML 1.0 +import PropertiesPanel 1.0 + +// Issue #407: top-level Window for native auto-rigging. Same Inspector-styled +// idiom as SkinWeightsDialog / QuadRetopoDialog. Operates on the currently +// selected STATIC entity — the button disables on already-rigged or empty +// selections (AutoRigController.hasRiggableSelection). +Window { + id: dialog + title: "Auto-Rig" + width: 560 + height: 420 + minimumWidth: 480 + minimumHeight: 380 + flags: Qt.Dialog + modality: Qt.ApplicationModal + color: PropertiesPanelController.panelColor + + property var templates: ["humanoid", "biped", "quadruped", "generic"] + property int templateIndex: 0 + property var upAxes: ["x", "y", "z"] + property int upAxisIndex: 1 // +Y default + property bool alsoSkin: true + + property string lastStatus: "" + property bool lastWasError: false + + function open() { + dialog.lastStatus = "" + dialog.lastWasError = false + dialog.show() + dialog.raise() + dialog.requestActivate() + keyCapture.forceActiveFocus() + } + + function runRig() { + if (AutoRigController.busy) return + if (!AutoRigController.hasRiggableSelection) return + const r = AutoRigController.autoRigSelected( + dialog.templates[dialog.templateIndex], + dialog.alsoSkin) + if (r && r.applied) { + dialog.lastStatus = + "Rigged: " + r.boneCount + " bones, " + + r.verticesSampled + " verts sampled, " + + r.jointsRecentered + " joints recentered" + + (dialog.alsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + 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.runRig() + event.accepted = true + } + } + } + + // ── Inline Inspector primitives (match SkinWeightsDialog) ─────────── + + component InspectorButton: Rectangle { + id: btn + property string label: "" + property bool buttonEnabled: true + signal clicked() + 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 + 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 InspectorCheckbox: Rectangle { + id: cb + property string label: "" + property bool checked: false + signal toggled() + 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 + 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() + } + } + + // A minimal segmented picker (no ComboBox dependency, matches the + // hand-rolled Inspector style). + component InspectorSegments: Row { + id: seg + property var options: [] + property int index: 0 + signal picked(int i) + spacing: 4 + Repeater { + model: seg.options + Rectangle { + width: Math.max(60, segText.implicitWidth + 18) + height: 24 + radius: 3 + color: index === seg.index + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: segText + anchors.centerIn: parent + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: seg.picked(index) + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.85 + text: "Embed a skeleton template into the selected unrigged mesh. " + + "Native heuristic (no external deps): maps a proportional joint " + + "graph into the mesh bounds and recentres joints toward the " + + "mesh's medial mass. Works best on roughly upright, manifold, " + + "T/A-pose meshes with +Y up. Already-rigged meshes are not " + + "eligible." + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Skeleton:"; Layout.preferredWidth: 80 } + InspectorSegments { + options: dialog.templates + index: dialog.templateIndex + onPicked: function(i) { dialog.templateIndex = i } + } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: "Up axis:"; Layout.preferredWidth: 80 } + InspectorSegments { + options: dialog.upAxes + index: dialog.upAxisIndex + onPicked: function(i) { dialog.upAxisIndex = i } + } + InspectorLabel { + text: "(+Y is the in-app default after import)" + opacity: 0.7 + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + InspectorLabel { text: ""; Layout.preferredWidth: 80 } + InspectorCheckbox { + Layout.fillWidth: true + label: "Also compute skin weights (one-click rig + skin)" + checked: dialog.alsoSkin + onToggled: dialog.alsoSkin = !dialog.alsoSkin + } + } + + Item { Layout.fillHeight: true } + + InspectorLabel { + Layout.fillWidth: true + visible: dialog.lastStatus.length > 0 + text: dialog.lastStatus + wrapMode: Text.WordWrap + color: dialog.lastWasError ? "#cc4444" : "#3a8c3a" + } + + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + InspectorButton { + label: "Close" + Layout.preferredWidth: 90 + onClicked: dialog.close() + } + InspectorButton { + label: AutoRigController.busy ? "Rigging…" : "Auto-Rig" + Layout.preferredWidth: 160 + buttonEnabled: !AutoRigController.busy + && AutoRigController.hasRiggableSelection + onClicked: dialog.runRig() + } + } + } + + Connections { + target: AutoRigController + function onError(msg) { + dialog.lastStatus = "Failed: " + msg + dialog.lastWasError = true + } + } +} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 1b94886c1..2f4f4af2f 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -331,6 +331,23 @@ Rectangle { Component.onCompleted: content = skinningToolsComponent } + // ---- Rigging (Animation mode) ---- + // Issue #407: native auto-rig. Shown in Animation Mode for a + // STATIC (skeleton-less) selection — embedding a skeleton is the + // step that turns a static mesh into an animatable one, so it + // belongs next to Skinning. Gated on hasRiggableSelection (a + // static mesh); already-rigged meshes show the Skinning section + // instead. + CollapsibleSection { + title: "Rigging" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && AutoRigController.hasRiggableSelection + expanded: false + + Component.onCompleted: content = riggingToolsComponent + } + // ---- Texture Paint (Material mode) ---- // (Brush color/radius/strength/falloff live on the toolbar // paint-brush popup. The Inspector panel keeps only the @@ -1267,6 +1284,74 @@ Rectangle { } } + // ---- Rigging Tools Content (Animation mode) ---- + // Issue #407: native auto-rig. The "Auto-Rig…" button opens the dialog + // (template picker + skin checkbox); it disables on non-static meshes + // (AutoRigController.hasRiggableSelection). + Component { + id: riggingToolsComponent + + 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: "Embed a skeleton template (humanoid / biped / quadruped / " + + "generic) into the selected unrigged mesh, optionally skinning " + + "it in one click. Best on upright, manifold, T/A-pose meshes." + } + + Rectangle { + id: rigBtn + width: Math.min(parent.width - 16, rigLabel.implicitWidth + 16) + height: 26 + radius: 3 + opacity: AutoRigController.hasRiggableSelection ? 1.0 : 0.45 + color: rigMa.containsMouse && AutoRigController.hasRiggableSelection + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + activeFocusOnTab: AutoRigController.hasRiggableSelection + Accessible.role: Accessible.Button + Accessible.name: "Auto-Rig" + Keys.onSpacePressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + Keys.onReturnPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + Keys.onEnterPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() + border.color: rigBtn.activeFocus + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.borderColor + border.width: rigBtn.activeFocus ? 2 : 1 + + Text { + id: rigLabel + anchors.centerIn: parent + text: "Auto-Rig…" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: rigMa + anchors.fill: parent + hoverEnabled: true + enabled: AutoRigController.hasRiggableSelection + cursorShape: AutoRigController.hasRiggableSelection + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: root.openAutoRigDialog() + ToolTip.visible: containsMouse + ToolTip.delay: 500 + ToolTip.text: AutoRigController.hasRiggableSelection + ? "Generate a skeleton for this static mesh by embedding a template." + : "Select a static (unrigged) mesh first." + } + } + } + } + // ---- Edit Mode Tools Content ---- Component { id: editModeToolsComponent @@ -4104,6 +4189,22 @@ Rectangle { } } + // Issue #407: native auto-rig dialog. Same lazy-load idiom. + Loader { + id: autoRigLoader + active: false + anchors.centerIn: parent + source: "qrc:/MaterialEditorQML/AutoRigDialog.qml" + onLoaded: if (item && item.open) item.open() + } + function openAutoRigDialog() { + if (!autoRigLoader.active) { + autoRigLoader.active = true + } else if (autoRigLoader.item) { + autoRigLoader.item.open() + } + } + Loader { id: isometricSpritesLoader active: false diff --git a/src/CLIPipeline_cmdrig_coverage_test.cpp b/src/CLIPipeline_cmdrig_coverage_test.cpp new file mode 100644 index 000000000..85344a997 --- /dev/null +++ b/src/CLIPipeline_cmdrig_coverage_test.cpp @@ -0,0 +1,138 @@ +// Coverage tests for CLIPipeline::cmdRig (#407, auto-rig). Mirrors the +// cmdSkin coverage style: the argument-validation branches (return 2) and the +// file-not-found branch (return 1) need no GL context, so they exercise the +// parser without a loaded mesh. The full rig+export path needs a real mesh and +// is exercised under Xvfb on CI via the success-path test below (which is +// skipped gracefully when Ogre can't init). + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CLIPipeline.h" +#include "TestHelpers.h" + +namespace { + +// RAII argc/argv builder, own anon-namespace name (no ODR clash). +class RigArgv { +public: + RigArgv(std::initializer_list args) + { + for (auto* a : args) m_storage.push_back(QByteArray(a)); + for (auto& ba : m_storage) m_argv.push_back(ba.data()); + m_argc = static_cast(m_argv.size()); + } + int argc() const { return m_argc; } + char** argv() { return m_argv.data(); } +private: + QList m_storage; + QList m_argv; + int m_argc = 0; +}; + +const char* kMissingFile = "/nonexistent_qtmesh_rig_input_zzz.obj"; + +} // namespace + +// ── Required-argument checks (return 2) ───────────────────────────────────── + +TEST(CLIPipelineCmdRigCoverageError, NoInputFile) +{ + RigArgv args({"rig"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, NoInputButFlags) +{ + RigArgv args({"rig", "--json", "--skin"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, InputButNoOutput) +{ + RigArgv args({"rig", kMissingFile}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +TEST(CLIPipelineCmdRigCoverageError, BadUpAxisIsUsageError) +{ + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--up-axis", "w"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 2); +} + +// ── File-existence branch (return 1) ──────────────────────────────────────── + +TEST(CLIPipelineCmdRigCoverageError, MissingFileWithValidArgs) +{ + // Valid template + output, but the input doesn't exist -> 1. + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--skeleton", "humanoid"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdRigCoverageError, UnknownTemplateStillParsesThenFileMissing) +{ + // An unrecognised template name is tolerated by templateFromString + // (falls back to humanoid), so it must NOT be a usage error (2); + // it proceeds to the file-existence check -> 1. + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--skeleton", "dragon"}); + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1); +} + +TEST(CLIPipelineCmdRigCoverageError, EveryValidUpAxisParses) +{ + for (const char* ax : {"x", "y", "z"}) { + RigArgv args({"rig", kMissingFile, "-o", "out.fbx", "--up-axis", ax}); + // Valid axis -> passes parse, then file-not-found -> 1 (never 2). + EXPECT_EQ(CLIPipeline::cmdRig(args.argc(), args.argv()), 1) + << "up-axis " << ax << " should parse"; + } +} + +// ── Success path (needs a GL/Ogre context; skipped without one) ───────────── + +TEST(CLIPipelineCmdRigSuccess, RigsStaticMeshAndExports) +{ + if (!tryInitOgre() || !canLoadMeshFiles()) + GTEST_SKIP() << "Ogre/GL unavailable (needs Xvfb)."; + + // Build a static (skeleton-less) mesh on disk by exporting a simple + // in-memory triangle mesh to OBJ — OBJ carries no skeleton. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // Reuse the editor's own loader path: write a minimal OBJ cube-ish quad. + const QString objPath = dir.filePath("static.obj"); + { + QFile f(objPath); + ASSERT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Text)); + // A small upright pyramid-ish shape (8 verts spanning a 1x2x1 box). + const char* obj = + "v -0.4 0 -0.4\nv 0.4 0 -0.4\nv 0.4 0 0.4\nv -0.4 0 0.4\n" + "v -0.2 2 -0.2\nv 0.2 2 -0.2\nv 0.2 2 0.2\nv -0.2 2 0.2\n" + "f 1 2 3\nf 1 3 4\nf 5 6 7\nf 5 7 8\n" + "f 1 2 6\nf 1 6 5\nf 3 4 8\nf 3 8 7\n"; + f.write(obj); + f.close(); + } + + const QString outPath = dir.filePath("rigged.gltf"); + // Hold the path bytes in stable std::strings so the argv char* stay valid. + const std::string objStr = objPath.toStdString(); + const std::string outStr = outPath.toStdString(); + RigArgv args({"rig", objStr.c_str(), "-o", outStr.c_str(), + "--skeleton", "humanoid"}); + const int rc = CLIPipeline::cmdRig(args.argc(), args.argv()); + // Either it rigs+exports (0) or the OBJ import path isn't available in this + // headless build (1) — but it must never crash or return a usage error. + EXPECT_NE(rc, 2); + if (rc == 0) + EXPECT_TRUE(QFile::exists(outPath)) << "rigged mesh should be written"; +} diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 5d8c88179..37caa75d7 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -39,6 +39,7 @@ #include "ScanEngine.h" #include "QuadRetopo.h" #include "SkinWeights.h" +#include "AutoRig.h" #include "MeshDepthRenderer.h" #include "ModelIsometricRenderer.h" #ifdef ENABLE_STABLE_DIFFUSION @@ -576,6 +577,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("auto_uv_unwrap"), &MCPServer::toolAutoUvUnwrap}, {QStringLiteral("retopologize"), &MCPServer::toolRetopologize}, {QStringLiteral("compute_skin_weights"), &MCPServer::toolComputeSkinWeights}, + {QStringLiteral("auto_rig"), &MCPServer::toolAutoRig}, {QStringLiteral("generate_mesh_texture"), &MCPServer::toolGenerateMeshTexture}, {QStringLiteral("generate_pbr_maps"), &MCPServer::toolGeneratePbrMaps}, {QStringLiteral("upscale_texture"), &MCPServer::toolUpscaleTexture}, @@ -1664,6 +1666,90 @@ QJsonObject MCPServer::toolComputeSkinWeights(const QJsonObject &args) return result; } +QJsonObject MCPServer::toolAutoRig(const QJsonObject &args) +{ + // Issue #407: native auto-rig of the selected STATIC mesh. Generates a + // skeleton from a template, binds it, optionally chains skin weights, and + // optionally re-exports. + if (!hasSelectedEntities()) + return makeErrorResult("No mesh selected. Load a mesh first with load_mesh."); + + if (args.contains("skin") && !args["skin"].isBool()) + return makeErrorResult("Error: 'skin' must be a boolean."); + + AutoRig::Options opts; + if (args.contains("template")) { + if (!args["template"].isString()) + return makeErrorResult("Error: 'template' must be a string."); + opts.tmpl = AutoRig::templateFromString(args["template"].toString()); + } + if (args.contains("up_axis")) { + const QString a = args["up_axis"].toString().toLower(); + if (a == "x") opts.upAxis = 0; + else if (a == "y") opts.upAxis = 1; + else if (a == "z") opts.upAxis = 2; + else return makeErrorResult("Error: 'up_axis' must be 'x', 'y', or 'z'."); + } + const bool alsoSkin = args.value("skin").toBool(false); + + SelectionSet* sel = SelectionSet::getSingleton(); + const QList resolved = sel ? sel->getResolvedEntities() + : QList{}; + if (resolved.isEmpty()) + return makeErrorResult("No selected entity."); + Ogre::Entity* entity = resolved.first(); + if (!entity) return makeErrorResult("Selected entity is null."); + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("auto_rig entity=%1 template=%2 skin=%3") + .arg(QString::fromStdString(entity->getName()), + AutoRig::templateToString(opts.tmpl)) + .arg(alsoSkin)); + + AutoRig::Report report; + bool skinned = false; + try { + report = AutoRig::rigEntity(entity, opts); + if (report.applied && alsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + skinned = sw.applied; + if (!sw.applied) + report.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } + } catch (const Ogre::Exception& e) { + return makeErrorResult(QStringLiteral("Ogre error: %1") + .arg(QString::fromStdString(e.getFullDescription()))); + } + + if (!report.applied) + return makeErrorResult(QStringLiteral("Auto-rig failed: %1").arg(report.error)); + + // Optional re-export of the now-rigged mesh. + const QString outputPath = args["output_path"].toString(); + if (!outputPath.isEmpty()) { + Ogre::SceneNode* node = entity->getParentSceneNode(); + if (!node) + return makeErrorResult( + QStringLiteral("Error: rigged, but the entity has no scene node to " + "export from")); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("auto_rig export to %1").arg(outputPath)); + const int rc = MeshImporterExporter::exporter( + node, outputPath, CLIPipeline::formatForExtension(outputPath)); + if (rc != 0) + return makeErrorResult( + QStringLiteral("Error: rigged but export to '%1' failed (code %2)") + .arg(outputPath).arg(rc)); + } + + QJsonObject result = makeSuccessResult(AutoRig::reportToText(report)); + QJsonObject j = AutoRig::reportToJson(report); + j["skinned"] = skinned; + result["rig"] = j; + return result; +} + QJsonObject MCPServer::toolGenerateMeshTexture(const QJsonObject &args) { #ifndef ENABLE_STABLE_DIFFUSION @@ -6365,6 +6451,35 @@ QJsonArray MCPServer::buildToolsList() ); } + // auto_rig (#407) + { + QJsonObject props; + props["template"] = QJsonObject{{"type", "string"}, + {"description", + "Skeleton template: 'humanoid' (19-bone, default), 'biped', " + "'quadruped', or 'generic' (3-joint spine fallback)."}}; + props["skin"] = QJsonObject{{"type", "boolean"}, + {"description", + "When true, also compute + apply skin weights so the mesh deforms " + "immediately (chains compute_skin_weights). Default false."}}; + props["up_axis"] = QJsonObject{{"type", "string"}, + {"description", "Mesh up axis: 'x', 'y' (default), or 'z'."}}; + props["output_path"] = QJsonObject{{"type", "string"}, + {"description", + "Optional path to re-export the rigged mesh. When omitted, the rig is " + "applied to the in-session scene only."}}; + appendTool( + "auto_rig", + "Auto-rig the currently selected STATIC (unrigged) mesh by embedding a " + "skeleton template into it (issue #407). Native heuristic (no external " + "deps): maps a proportional joint graph into the mesh AABB and recentres " + "joints toward the mesh's medial mass. Best on roughly upright, manifold, " + "T/A-pose meshes with +Y up. Already-skinned meshes are rejected. Pair " + "skin:true for a one-click rig+skin.", + props + ); + } + // generate_mesh_texture — only advertised when Stable Diffusion is // compiled in; the handler hard-fails otherwise, so publishing it on // a non-SD build would imply a capability the server can't satisfy. diff --git a/src/MCPServer.h b/src/MCPServer.h index e47413afe..b4faa8fff 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -157,6 +157,9 @@ private slots: /// Issue #402: compute skin weights via inverse-distance /// heuristic. Mesh must have a skeleton attached. QJsonObject toolComputeSkinWeights(const QJsonObject &args); + /// #407: native auto-rig of the selected static mesh (template embedding), + /// optional skin chain + re-export. + QJsonObject toolAutoRig(const QJsonObject &args); /// Issue #403: mesh-aware (depth-conditioned) texture /// generation. Renders the selected entity's depth map and /// conditions sd.cpp on it via a ControlNet depth model, then diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index e03fc373e..a4b713ec6 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -12,6 +12,7 @@ ../qml/UvUnwrapDialog.qml ../qml/QuadRetopoDialog.qml ../qml/SkinWeightsDialog.qml + ../qml/AutoRigDialog.qml ../qml/IsometricSpritesDialog.qml ../qml/qmldir ../qml/ThemedButton.qml From 8f2f68d1586ff64b516ade4cf02efff38db18b42 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 11:59:33 -0400 Subject: [PATCH 18/24] fix(#407): compile AutoRig.cpp into the test common lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unit-tests-linux failed to link: AutoRig::* symbols undefined in libqtmesh_test_common.a (MCPServer::toolAutoRig and CLIPipeline::cmdRig reference them). The test target has its own TEST_SRC_FILES list separate from the app's src/CMakeLists.txt — add AutoRig.cpp + AutoRigController.cpp there, next to SkinWeights (same omission class as #738's PbrMapSynth gap). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2778fdb6a..cd3a57df8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -137,6 +137,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/QuadRetopoController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeights.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SkinWeightsController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AutoRig.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AutoRigController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshDepthRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshOptimizerLod.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ExportOptimizer.cpp From 4cf89965e4b2bfa2343f050bade20e27aa488aeb Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 23 Jun 2026 15:54:29 -0400 Subject: [PATCH 19/24] =?UTF-8?q?fix(#407):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20QML=20registration,=20upAxis,=20error=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review (Codex + CodeRabbit) on the auto-rig PR: - CRITICAL: register AutoRigController as a QML singleton in mainwindow.cpp (PropertiesPanel URI) like the sibling controllers + add its kill(). With qt_add_qml_module disabled, QML_SINGLETON alone doesn't expose it, so the Rigging section/dialog would ReferenceError. Verified no error at runtime now. - CRITICAL: the dialog's Up-axis picker was ignored — autoRigSelected() didn't take upAxis. Added a `const QString& upAxis` param (controller maps x/y/z → Options::upAxis) and pass dialog.upAxes[dialog.upAxisIndex] from QML. - AutoRig::appendPositions: guard a null vbuf->lock() (shrink `out` back, return false) instead of dereferencing. - AutoRig::rigEntity: on _initialise failure, detach the half-built skeleton (mesh->_notifySkeleton(null)) before removing it, so hasSkeleton() resets and a retry / exporter doesn't pick up a partial rig. - MCP toolAutoRig: validate output_path type; a requested skin that fails is now a hard error (no unskinned export reported as success); export wrapped in the try/catch (also catches std::exception); Sentry breadcrumb no longer logs the full output path. - PropertiesPanel openAutoRigDialog(): handle Loader.Error to allow retry. App + UnitTests build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- qml/AutoRigDialog.qml | 1 + qml/PropertiesPanel.qml | 4 +++ src/AutoRig.cpp | 13 ++++++++ src/AutoRigController.cpp | 10 ++++-- src/AutoRigController.h | 1 + src/MCPServer.cpp | 64 ++++++++++++++++++++++++--------------- src/mainwindow.cpp | 6 ++++ 7 files changed, 73 insertions(+), 26 deletions(-) diff --git a/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml index e3843a6e5..c6e8c5843 100644 --- a/qml/AutoRigDialog.qml +++ b/qml/AutoRigDialog.qml @@ -43,6 +43,7 @@ Window { if (!AutoRigController.hasRiggableSelection) return const r = AutoRigController.autoRigSelected( dialog.templates[dialog.templateIndex], + dialog.upAxes[dialog.upAxisIndex], dialog.alsoSkin) if (r && r.applied) { dialog.lastStatus = diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 2f4f4af2f..919d468ee 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4202,6 +4202,10 @@ Rectangle { autoRigLoader.active = true } else if (autoRigLoader.item) { autoRigLoader.item.open() + } else if (autoRigLoader.status === Loader.Error) { + // Failed load left active=true / item=null — reset so a retry works. + autoRigLoader.active = false + autoRigLoader.active = true } } diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp index 7cdb2abd3..e995cbf38 100644 --- a/src/AutoRig.cpp +++ b/src/AutoRig.cpp @@ -225,6 +225,12 @@ bool appendPositions(Ogre::VertexData* vd, std::vector& out) const size_t stride = vbuf->getVertexSize(); auto* base = static_cast( vbuf->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!base) { + // Lock can fail (write-only buffer with no shadow copy, etc.). Shrink + // back to the pre-grow size so the unread slots don't inflate vcount. + out.resize(base0); + return false; + } for (size_t i = 0; i < vd->vertexCount; ++i) { float* p = nullptr; posElem->baseVertexPointerToElement(base + i * stride, &p); @@ -330,6 +336,13 @@ AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) } catch (const Ogre::Exception& e) { report.error = QStringLiteral("Ogre error building skeleton: %1") .arg(QString::fromStdString(e.getFullDescription())); + // Detach the half-built skeleton from the mesh BEFORE removing the + // resource. _notifySkeleton(skel) ran before entity->_initialise; if + // the latter threw, the mesh still references the skeleton, so + // mesh->hasSkeleton() would stay true — a later rigEntity() would bail + // with "mesh already has a skeleton" and exporters could pick up the + // half-built rig. Reset it to a clean static mesh. + mesh->_notifySkeleton(Ogre::SkeletonPtr()); if (skel && skelMgr.resourceExists(skelName)) skelMgr.remove(skelName); report.applied = false; } diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp index 739d90d29..cfbe9abd3 100644 --- a/src/AutoRigController.cpp +++ b/src/AutoRigController.cpp @@ -51,13 +51,15 @@ bool AutoRigController::hasRiggableSelection() const } QVariantMap AutoRigController::autoRigSelected(const QString& templateName, + const QString& upAxis, bool alsoSkin) { QVariantMap result; SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), - QStringLiteral("Auto-rig requested (%1%2)") - .arg(templateName, alsoSkin ? QStringLiteral(", +skin") : QString())); + QStringLiteral("Auto-rig requested (%1, up=%2%3)") + .arg(templateName, upAxis, + alsoSkin ? QStringLiteral(", +skin") : QString())); auto* sel = SelectionSet::getSingleton(); const auto entities = sel ? sel->getResolvedEntities() : QList{}; @@ -79,6 +81,10 @@ QVariantMap AutoRigController::autoRigSelected(const QString& templateName, AutoRig::Options opts; opts.tmpl = AutoRig::templateFromString(templateName); + const QString ax = upAxis.trimmed().toLower(); + if (ax == QStringLiteral("x")) opts.upAxis = 0; + else if (ax == QStringLiteral("z")) opts.upAxis = 2; + else opts.upAxis = 1; // y (default) SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), QStringLiteral("UI auto-rig entity=%1 template=%2") diff --git a/src/AutoRigController.h b/src/AutoRigController.h index 7ddc8fee0..3a1ecfc9f 100644 --- a/src/AutoRigController.h +++ b/src/AutoRigController.h @@ -35,6 +35,7 @@ class AutoRigController : public QObject /// Returns a QVariantMap mirroring AutoRig::Report (+ a `skinned` bool). /// Emits `rigged(report)` on success or `error(msg)` on failure. Q_INVOKABLE QVariantMap autoRigSelected(const QString& templateName, + const QString& upAxis, bool alsoSkin); signals: diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 37caa75d7..77db11c48 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -1706,41 +1706,57 @@ QJsonObject MCPServer::toolAutoRig(const QJsonObject &args) AutoRig::templateToString(opts.tmpl)) .arg(alsoSkin)); + // Validate output_path type up front (like 'skin'/'template') — a + // non-string would otherwise coerce to "" and silently skip the export + // while still reporting success. + if (args.contains("output_path") && !args["output_path"].isString()) + return makeErrorResult("Error: 'output_path' must be a string."); + const QString outputPath = args.value("output_path").toString(); + AutoRig::Report report; bool skinned = false; + // Wrap the full mutating + export section so export failures and + // std::runtime_error (not just Ogre::Exception) reach the MCP error path. try { report = AutoRig::rigEntity(entity, opts); - if (report.applied && alsoSkin) { + if (!report.applied) + return makeErrorResult( + QStringLiteral("Auto-rig failed: %1").arg(report.error)); + + if (alsoSkin) { const auto sw = SkinWeights::computeAndApply(entity, {}); skinned = sw.applied; + // A requested skin that failed is a hard error — don't export an + // unskinned asset and report success. if (!sw.applied) - report.error = QStringLiteral("rigged, but skinning failed: %1") - .arg(sw.error); + return makeErrorResult(QStringLiteral( + "Auto-rig succeeded, but the requested skinning failed: %1") + .arg(sw.error)); + } + + // Optional re-export of the now-rigged mesh. + if (!outputPath.isEmpty()) { + Ogre::SceneNode* node = entity->getParentSceneNode(); + if (!node) + return makeErrorResult( + QStringLiteral("Error: rigged, but the entity has no scene " + "node to export from")); + // Don't leak the full local path (usernames / private dirs) to Sentry. + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("auto_rig export requested")); + const int rc = MeshImporterExporter::exporter( + node, outputPath, CLIPipeline::formatForExtension(outputPath)); + if (rc != 0) + return makeErrorResult( + QStringLiteral("Error: rigged but export to '%1' failed (code %2)") + .arg(outputPath).arg(rc)); } } catch (const Ogre::Exception& e) { return makeErrorResult(QStringLiteral("Ogre error: %1") .arg(QString::fromStdString(e.getFullDescription()))); - } - - if (!report.applied) - return makeErrorResult(QStringLiteral("Auto-rig failed: %1").arg(report.error)); - - // Optional re-export of the now-rigged mesh. - const QString outputPath = args["output_path"].toString(); - if (!outputPath.isEmpty()) { - Ogre::SceneNode* node = entity->getParentSceneNode(); - if (!node) - return makeErrorResult( - QStringLiteral("Error: rigged, but the entity has no scene node to " - "export from")); - SentryReporter::addBreadcrumb(QStringLiteral("file.export"), - QStringLiteral("auto_rig export to %1").arg(outputPath)); - const int rc = MeshImporterExporter::exporter( - node, outputPath, CLIPipeline::formatForExtension(outputPath)); - if (rc != 0) - return makeErrorResult( - QStringLiteral("Error: rigged but export to '%1' failed (code %2)") - .arg(outputPath).arg(rc)); + } catch (const std::exception& e) { + return makeErrorResult(QStringLiteral("Auto-rig error: %1") + .arg(QString::fromUtf8(e.what()))); } QJsonObject result = makeSuccessResult(AutoRig::reportToText(report)); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 64e666b98..f2974f060 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -101,6 +101,7 @@ #include "UvUnwrapController.h" #include "QuadRetopoController.h" #include "SkinWeightsController.h" +#include "AutoRigController.h" #include "MeshDepthRenderer.h" #include "MaterialPresetLibrary.h" #include "MaterialPreviewRenderer.h" @@ -649,6 +650,11 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return SkinWeightsController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "AutoRigController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return AutoRigController::qmlInstance(engine, nullptr); + }); #ifdef ENABLE_AUTO_UPDATER qmlRegisterSingletonType( "Updater", 1, 0, "UpdaterController", From e41eb80d917e96b1add8aa8157321a5bb78f2f8f Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 09:55:50 -0400 Subject: [PATCH 20/24] feat(#407): Mixamo-style marker placement + undoable rig/skin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker-guided auto-rig refinement: the user clicks 10 humanoid markers on the mesh in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, pelvis) and each anchors its joint while the limb/spine chains interpolate between the anchors — so the rig follows real (incl. cartoon) proportions instead of the fixed proportional template. - AutoRig::fitTemplateWithMarkers + layChain (generic anchor→tip chain): arms lay shoulder→arm→forearm→hand toward the wrist; legs lay hip socket→knee→foot; spine distributes Spine/Chest/Neck evenly between the marked pelvis and chin; hips carry unmarked thigh roots, explicit hip markers override. Every marker optional (empty set ≡ fitTemplate). - AutoRigController marker session (begin/skip/undo/cancel/commit) with ray-picked PT_SPHERE overlays; clicks routed via TransformOperator before the knife/select paths. AutoRigDialog made non-modal so viewport clicks reach the scene; onClosing cancels any active session. - Undo/redo: AutoRig::unrigEntity + AutoRigCommand wrap rig (+ optional skin) in one undoable unit; both GUI paths push through UndoManager. Single Ctrl+Z reverts rig and skin together. - Skeleton section extracted in the Inspector (skeleton/weights toggles no longer gated on animations). - Tests: marker order/labels, empty≡fitTemplate, arm/leg chain layout, shoulder/hip anchoring, spine interpolation, command error/undo branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- qml/AutoRigDialog.qml | 109 +++++++- qml/PropertiesPanel.qml | 135 +++++++-- src/AutoRig.cpp | 261 ++++++++++++++++- src/AutoRig.h | 61 ++++ src/AutoRigController.cpp | 404 ++++++++++++++++++++++++++- src/AutoRigController.h | 57 ++++ src/AutoRig_test.cpp | 272 ++++++++++++++++++ src/CMakeLists.txt | 1 + src/PropertiesPanelController.cpp | 25 ++ src/PropertiesPanelController.h | 12 + src/TransformOperator.cpp | 11 + src/commands/AutoRigCommand.cpp | 61 ++++ src/commands/AutoRigCommand.h | 61 ++++ src/commands/AutoRigCommand_test.cpp | 101 +++++++ tests/CMakeLists.txt | 1 + 16 files changed, 1537 insertions(+), 37 deletions(-) create mode 100644 src/commands/AutoRigCommand.cpp create mode 100644 src/commands/AutoRigCommand.h create mode 100644 src/commands/AutoRigCommand_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 64f0a9637..ff4d6ae0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Real-ESRGAN texture upscaling** (`src/TextureUpscaler.h/cpp` + `AIAssistManager`, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. `TextureUpscaler` is the Ogre-free core (reuses `PbrMapSynth::toNCHW`/`nchwToRgb`): a **scale-aware** overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). `AIAssistManager::upscaleTexture(srcPath, scale, overwrite)` extends the per-model `Map` enum with `UpscaleX2`/`UpscaleX4`, downloads the model on first use (same HF repo), runs, caches `_upscaled_x{2,4}.png` next to the source, and emits `upscaleStarted/Completed/Error`. The Material Editor path is worker-threaded and reports state via `upscaleDownloading` (first-run model fetch) / `upscaleProgress(done,total)` (per tile) / `upscaleCompleted`/`upscaleError`; `cancelUpscale()` flips a shared atomic that the tiling loop's `ProgressFn` checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. **Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, [xinntao](https://github.com/xinntao/Real-ESRGAN))** — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via `scripts/export-realesrgan-onnx.py` (one-time, offline, NOT shipped). Surfaced via **CLI `qtmesh material --texture --upscale {2|4} [-o ]`** (`CLIPipeline::cmdMaterialUpscale`), the MCP `upscale_texture` tool, and **"Upscale 2× / 4×" buttons** in the Material Editor's Texture Properties panel. Sentry breadcrumb category `ai.assist.upscale`. ONNX intra-op threads are set to `hardware_concurrency-1` (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — `PbrMapSynth` stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded. - **LLM-assisted material from a description** (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → `MaterialEditorQML::generateMaterialFromPrompt` → `LLMManager::generateMaterial`); #406 adds the missing **CLI + MCP parity** by reusing that exact path headlessly. The shared core `CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)` resolves a GGUF model (the `--model`/`model` override, else last-used / first available via `LLMManager::scanForModels`+`availableModels`), drives `LLMManager::generateMaterial` synchronously through two `QEventLoop`s (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the `material ` header, parses the script via `MaterialManager::parseScript`, `compile()`s, honors a `pbr_workflow` tag through `RTShaderHelper::applyPbrIfTagged`, and binds the material to every submesh of the entity. The **CLI** `qtmesh material --describe "" [--model ] [-o out]` (`CLIPipeline::cmdMaterialDescribe`) imports → applies → re-exports; the **MCP** `describe_material` tool (`MCPServer::toolDescribeMaterial`, args `{prompt, mesh?, model?, output_path?}`) applies to the named/selected entity in-session and optionally re-exports when `output_path` is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — `LLMManager.cpp` always compiles, so no `#ifdef ENABLE_LOCAL_LLM` guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category `ai.assist.describe_material`. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer. - **SkinWeights** (`src/SkinWeights.h/cpp`, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights. The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is **GPL/copyleft**. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with **zero new dependencies**: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply `1/dist^falloff` weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (`maxInfluenceDistance` × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional `skipUnweightedBones` filters Mixamo helper bones. `replaceExisting=false` enables a merge mode for "fill in missing weights" workflows. Surfaced via `qtmesh skin --max-influences N --falloff F -o out`, MCP `compute_skin_weights`, and the **Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button** (`qml/SkinWeightsDialog.qml`, driven by `SkinWeightsController` singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to `hasSkinnedSelection` so it disables on static (skeleton-less) meshes. The GUI path runs through `ComputeSkinWeightsCommand` (`src/commands/`) so the auto-skin is **undoable** (Ctrl+Z): the command snapshots every submesh's `VertexBoneAssignmentList` (+ the mesh-level shared list) before the first `redo`, runs `computeAndApply`, and on `undo` restores the snapshot and calls `_compileBoneAssignments` to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category `ai.assist.skin_weights`. A future slice can plug libigl BBW in behind `-DENABLE_LIBIGL_BBW` for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip. -- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. +- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. **Mixamo-style marker placement** (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — `AutoRig::humanoidMarkerOrder()`), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is `AutoRig::fitTemplateWithMarkers` (runs `fitTemplate`, then: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→`layChain` lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→`layLeg` anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). `layChain` is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (`report.markersApplied` counts the placed ones; an empty marker set is bit-identical to `fitTemplate`). The viewport flow lives in `AutoRigController` (marker-session state machine: `beginMarkerPlacement`/`skipCurrentMarker`/`undoLastMarker`/`cancelMarkerPlacement`/`commitMarkerRig`); clicks are routed in by `TransformOperator::mousePressEvent` (checked **before** the knife/select paths when `markerMode()` is true), ray-cast to the mesh surface (`getCameraToViewportRay` → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow `PT_SPHERE` overlays. The `qml/AutoRigDialog.qml` is **non-modal** (`Qt.NonModal`) so viewport clicks reach the 3D scene, and `onClosing` cancels any active marker session. Surfaced via the "Place markers…" button + Skip/Undo/Cancel/"Rig from markers" in-session controls in the dialog (no CLI/MCP marker surface — guided placement is inherently interactive). - **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%. - **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering. - **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`. diff --git a/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml index c6e8c5843..b077df2d6 100644 --- a/qml/AutoRigDialog.qml +++ b/qml/AutoRigDialog.qml @@ -17,9 +17,14 @@ Window { minimumWidth: 480 minimumHeight: 380 flags: Qt.Dialog - modality: Qt.ApplicationModal + // NON-modal: marker placement needs the user to click in the 3D viewport, + // which an application-modal dialog would block. Stays on top instead. + modality: Qt.NonModal color: PropertiesPanelController.panelColor + // Leaving the dialog must not strand the viewport in marker-capture mode. + onClosing: if (AutoRigController.markerMode) AutoRigController.cancelMarkerPlacement() + property var templates: ["humanoid", "biped", "quadruped", "generic"] property int templateIndex: 0 property var upAxes: ["x", "y", "z"] @@ -58,6 +63,21 @@ Window { } } + function runMarkerRig() { + if (AutoRigController.busy) return + const r = AutoRigController.commitMarkerRig(dialog.alsoSkin) + if (r && r.applied) { + dialog.lastStatus = + "Rigged from markers: " + r.boneCount + " bones, " + + r.markersApplied + " markers applied" + + (dialog.alsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + dialog.lastWasError = false + } else { + dialog.lastStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + dialog.lastWasError = true + } + } + Item { id: keyCapture anchors.fill: parent @@ -250,6 +270,93 @@ Window { } } + // ── Mixamo-style marker placement ─────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 6 + height: markerCol.implicitHeight + 16 + color: PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + radius: 3 + + ColumnLayout { + id: markerCol + anchors.fill: parent + anchors.margins: 8 + spacing: 6 + + InspectorLabel { + Layout.fillWidth: true + wrapMode: Text.WordWrap + opacity: 0.85 + text: "Better fit: place markers on the mesh (Mixamo-style). " + + "Click each point in the viewport; the skeleton fits the " + + "marked limbs instead of fixed proportions. Unmarked → template." + } + + // Active-mode guidance: which marker to click + progress. + InspectorLabel { + Layout.fillWidth: true + visible: AutoRigController.markerMode + wrapMode: Text.WordWrap + color: PropertiesPanelController.highlightColor + text: AutoRigController.currentMarkerLabel.length > 0 + ? ("Click: " + AutoRigController.currentMarkerLabel + + " (" + AutoRigController.markerCount + "/" + + AutoRigController.markerTotal + " placed)") + : ("All markers placed (" + AutoRigController.markerCount + + "/" + AutoRigController.markerTotal + ") — click 'Rig from markers'") + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + // Enter marker mode. + InspectorButton { + visible: !AutoRigController.markerMode + label: "Place markers…" + Layout.preferredWidth: 130 + buttonEnabled: !AutoRigController.busy + && AutoRigController.hasRiggableSelection + onClicked: AutoRigController.beginMarkerPlacement(dialog.upAxes[dialog.upAxisIndex]) + } + // In-session controls. + InspectorButton { + visible: AutoRigController.markerMode + label: "Skip" + Layout.preferredWidth: 64 + buttonEnabled: AutoRigController.currentMarkerLabel.length > 0 + onClicked: AutoRigController.skipCurrentMarker() + } + InspectorButton { + visible: AutoRigController.markerMode + label: "Undo" + Layout.preferredWidth: 64 + buttonEnabled: AutoRigController.markerCount > 0 + onClicked: AutoRigController.undoLastMarker() + } + InspectorButton { + visible: AutoRigController.markerMode + label: "Cancel" + Layout.preferredWidth: 72 + onClicked: AutoRigController.cancelMarkerPlacement() + } + Item { Layout.fillWidth: true } + InspectorButton { + visible: AutoRigController.markerMode + label: AutoRigController.busy ? "Rigging…" : "Rig from markers" + Layout.preferredWidth: 150 + // Allow committing once at least one marker is placed + // (the rest fall back to the template). + buttonEnabled: !AutoRigController.busy + && AutoRigController.markerCount > 0 + onClicked: dialog.runMarkerRig() + } + } + } + } + Item { Layout.fillHeight: true } InspectorLabel { diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 919d468ee..a5abb4c5d 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -348,6 +348,25 @@ Rectangle { Component.onCompleted: content = riggingToolsComponent } + // ---- Skeleton (Animation mode) ---- + // Bone/skeleton visualization toggles (skeleton overlay + bone-weight + // heat-map). Lives in its OWN section, independent of animation clips, + // so it surfaces for ANY skinned mesh — including a skeleton-bearing + // mesh with no animations yet (e.g. a freshly auto-rigged static + // mesh). Previously these toggles were buried per-animation-group + // inside the Animations section and never appeared without clips. + // This is the home for future bone-level features (bone select, + // per-bone transforms, etc.). + CollapsibleSection { + title: "Skeleton" + sectionVisible: root.currentTab === root.modeToolsTab + && root.modeToolMatches(EditorModeController.AnimationMode) + && PropertiesPanelController.hasSkeletonSelection + expanded: false + + Component.onCompleted: content = skeletonToolsComponent + } + // ---- Texture Paint (Material mode) ---- // (Brush color/radius/strength/falloff live on the toolbar // paint-brush popup. The Inspector panel keeps only the @@ -1352,6 +1371,96 @@ Rectangle { } } + // ---- Skeleton Tools Content (Animation mode) ---- + // Per-entity skeleton/bone visualization toggles, sourced from + // PropertiesPanelController.skeletonData() (skeleton-bearing entities, + // independent of animation clips). Refreshes on selectionChanged / + // animationStateChanged so a just-auto-rigged mesh shows up immediately. + Component { + id: skeletonToolsComponent + + Column { + id: skeletonToolsCol + width: parent ? parent.width : 200 + padding: 8 + spacing: 8 + + property var skelGroups: PropertiesPanelController.skeletonData() + Connections { + target: PropertiesPanelController + function onAnimationStateChanged() { + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + function onSelectionChanged() { + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + } + + Text { + width: parent.width - 16 + wrapMode: Text.Wrap + opacity: 0.8 + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: "Visualize the skeleton and per-vertex bone weights for the " + + "selected skinned mesh." + } + + Repeater { + model: skeletonToolsCol.skelGroups + delegate: Column { + required property var modelData + width: skeletonToolsCol.width - 16 + spacing: 4 + + // Entity name (only worth showing when multiple are selected). + Text { + visible: skeletonToolsCol.skelGroups.length > 1 + text: modelData.entity + color: PropertiesPanelController.textColor + opacity: 0.7 + font.pixelSize: 10 + } + + Row { + spacing: 8 + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: modelData.showSkeleton ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor + Text { anchors.centerIn: parent; text: modelData.showSkeleton ? "✓" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + PropertiesPanelController.toggleSkeletonDebug(modelData.entity, !modelData.showSkeleton) + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + } + } + Text { text: "Skeleton"; color: PropertiesPanelController.textColor; font.pixelSize: 11; anchors.verticalCenter: parent.verticalCenter } + + Rectangle { + width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter + border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 + color: modelData.showWeights ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor + Text { anchors.centerIn: parent; text: modelData.showWeights ? "✓" : ""; color: "white"; font.pixelSize: 10 } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + PropertiesPanelController.toggleBoneWeights(modelData.entity, !modelData.showWeights) + skeletonToolsCol.skelGroups = PropertiesPanelController.skeletonData() + } + } + } + Text { text: "Weights"; color: PropertiesPanelController.textColor; font.pixelSize: 11; anchors.verticalCenter: parent.verticalCenter } + } + } + } + } + } + // ---- Edit Mode Tools Content ---- Component { id: editModeToolsComponent @@ -5326,29 +5435,9 @@ Rectangle { } } - // Skeleton/Weights row (if has skeleton) - Row { - visible: grp.hasSkeleton - spacing: 8; topPadding: 4 - - Rectangle { - width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter - border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 - color: grp.showSkeleton ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor - Text { anchors.centerIn: parent; text: grp.showSkeleton ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } - MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleSkeletonDebug(grp.entity, !grp.showSkeleton) } - } - Text { text: "Skeleton"; color: PropertiesPanelController.textColor; font.pixelSize: 10; anchors.verticalCenter: parent.verticalCenter } - - Rectangle { - width: 14; height: 14; anchors.verticalCenter: parent.verticalCenter - border.color: PropertiesPanelController.borderColor; border.width: 1; radius: 2 - color: grp.showWeights ? PropertiesPanelController.highlightColor : PropertiesPanelController.controlBgColor - Text { anchors.centerIn: parent; text: grp.showWeights ? "\u2713" : ""; color: "white"; font.pixelSize: 10 } - MouseArea { anchors.fill: parent; onClicked: PropertiesPanelController.toggleBoneWeights(grp.entity, !grp.showWeights) } - } - Text { text: "Weights"; color: PropertiesPanelController.textColor; font.pixelSize: 10; anchors.verticalCenter: parent.verticalCenter } - } + // (Skeleton / Weights viz toggles moved to the dedicated + // "Skeleton" section so they surface for skinned meshes + // regardless of whether they have animation clips.) // Export Pose button (if has skeleton) Rectangle { diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp index e995cbf38..49ad7317b 100644 --- a/src/AutoRig.cpp +++ b/src/AutoRig.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -208,6 +210,218 @@ std::vector AutoRig::fitTemplate(const std::vector& tmpl, return placed; } +QString AutoRig::markerLabel(MarkerId id) +{ + switch (id) { + case MarkerId::Chin: return QStringLiteral("Chin"); + case MarkerId::LeftShoulder: return QStringLiteral("Left shoulder"); + case MarkerId::RightShoulder: return QStringLiteral("Right shoulder"); + case MarkerId::LeftWrist: return QStringLiteral("Left wrist"); + case MarkerId::RightWrist: return QStringLiteral("Right wrist"); + case MarkerId::LeftUpLeg: return QStringLiteral("Left hip"); + case MarkerId::RightUpLeg: return QStringLiteral("Right hip"); + case MarkerId::LeftKnee: return QStringLiteral("Left knee"); + case MarkerId::RightKnee: return QStringLiteral("Right knee"); + case MarkerId::Hips: return QStringLiteral("Hips"); + case MarkerId::Count: break; + } + return QStringLiteral("?"); +} + +std::vector AutoRig::humanoidMarkerOrder() +{ + // Order = top-down, then limbs: chin, both shoulders, both wrists, both + // hips (thigh roots), both knees, pelvis. Shoulders precede wrists, and the + // hip sockets precede knees, so each limb chain has its attach point placed + // before its tip. Pelvis (Hips) last so it can carry any unmarked thigh + // roots along without overriding ones the user pinned. + return { MarkerId::Chin, + MarkerId::LeftShoulder, MarkerId::RightShoulder, + MarkerId::LeftWrist, MarkerId::RightWrist, + MarkerId::LeftUpLeg, MarkerId::RightUpLeg, + MarkerId::LeftKnee, MarkerId::RightKnee, + MarkerId::Hips }; +} + +namespace { + +// Find a placed joint by name; returns nullptr if absent. +AutoRig::Joint* findJoint(std::vector& js, const char* name) +{ + for (auto& j : js) + if (j.name == QLatin1String(name)) return &j; + return nullptr; +} + +// Place `mid` between `a` and `b` at parameter t (0=a, 1=b). +std::array lerp3(const std::array& a, + const std::array& b, double t) +{ + return { a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t }; +} + +// Lay an N-joint limb chain straight along anchor→marker. `names` is the +// chain in parent→child order; the FIRST joint (the anchor — e.g. the +// shoulder) keeps its template position, the LAST goes to the marker, and +// every joint in between is distributed evenly by index (a straight rest-pose +// limb). Distributing ALL the intermediate joints — not just one midpoint — +// is what makes the whole limb reach toward the marker; anchoring only the +// tip + a single mid leaves the upper segment tucked at its template position. +// Any named joint that's missing is skipped (the rest still lay out from the +// surviving anchor/tip). +void layChain(std::vector& js, + std::initializer_list names, + const std::array& tipMarker) +{ + if (names.size() < 2) return; + AutoRig::Joint* anchor = findJoint(js, *names.begin()); + if (!anchor) return; + const auto a = anchor->pos; // copy: stays put, drives the lerp + const int last = static_cast(names.size()) - 1; + int i = 0; + for (const char* n : names) { + if (i > 0) { // i==0 is the anchor; leave it + if (auto* j = findJoint(js, n)) + j->pos = lerp3(a, tipMarker, static_cast(i) / last); + } + ++i; + } +} + +} // namespace + +std::vector AutoRig::fitTemplateWithMarkers( + const std::vector& tmpl, + const float* verts, int vertexCount, + const std::vector& markers, + const Options& opts, + int* outRecentered, int* outMarkersApplied) +{ + // Start from the proportional fit; markers refine it. + std::vector placed = fitTemplate(tmpl, verts, vertexCount, opts, outRecentered); + if (outMarkersApplied) *outMarkersApplied = 0; + + auto get = [&](MarkerId id) -> const Marker* { + for (const auto& m : markers) + if (m.id == id && m.set) return &m; + return nullptr; + }; + int applied = 0; + + // Hips: anchor the pelvis directly, and carry the thigh roots + // (LeftUpLeg / RightUpLeg — children of Hips in the template) along with it + // by the same delta, so marking the hips moves the whole pelvis+thigh-root + // cluster as a unit instead of leaving the thighs floating at their + // template position. (An explicit L/R-hip marker below overrides its root.) + if (const Marker* m = get(MarkerId::Hips)) { + if (auto* hips = findJoint(placed, "Hips")) { + const std::array d = { m->pos[0] - hips->pos[0], + m->pos[1] - hips->pos[1], + m->pos[2] - hips->pos[2] }; + hips->pos = m->pos; + for (const char* leg : {"LeftUpLeg", "RightUpLeg"}) { + if (auto* j = findJoint(placed, leg)) + j->pos = { j->pos[0]+d[0], j->pos[1]+d[1], j->pos[2]+d[2] }; + } + ++applied; + } + } + // Chin: anchor Head, then lay the SPINE straight from the pelvis up to the + // head so the torso follows the marked hips↔chin span instead of leaving + // Spine/Chest/Neck stranded at their template heights. The spine joints are + // distributed evenly between Hips and Head by index (cartoon torsos vary a + // lot in length, so a proportional template guess is usually wrong). + if (const Marker* m = get(MarkerId::Chin)) { + if (auto* head = findJoint(placed, "Head")) { + head->pos = m->pos; + ++applied; + // Anchor at the (marked-or-template) Hips; lay Spine→Chest→Neck→Head. + if (auto* hips = findJoint(placed, "Hips")) { + // Spine chain joints in parent→child order, Head is the tip. + static const char* kSpine[] = + { "Spine", "Chest", "Neck" }; // between Hips and Head + const auto a = hips->pos; + const int last = static_cast(std::size(kSpine)) + 1; // +Head + for (int i = 0; i < static_cast(std::size(kSpine)); ++i) { + if (auto* j = findJoint(placed, kSpine[i])) + j->pos = lerp3(a, m->pos, + static_cast(i + 1) / last); + } + } else if (auto* neck = findJoint(placed, "Neck")) { + // No hips reference — fall back to the old neck lift. + if (auto* chest = findJoint(placed, "Chest")) + neck->pos = lerp3(chest->pos, m->pos, 0.5); + } + } + } + // Shoulders: anchor the arm-chain attach point. Applied BEFORE the wrist + // chains so layChain (which uses the shoulder as its fixed anchor) lays the + // arm out from the marked shoulder rather than the template one. A shoulder + // marker on its own (no wrist) still repositions the attach point. + if (const Marker* m = get(MarkerId::LeftShoulder)) { + if (auto* j = findJoint(placed, "LeftShoulder")) { j->pos = m->pos; ++applied; } + } + if (const Marker* m = get(MarkerId::RightShoulder)) { + if (auto* j = findJoint(placed, "RightShoulder")) { j->pos = m->pos; ++applied; } + } + // Arms: the wrist marker is the hand position. Lay the whole arm chain + // straight from the SHOULDER (anchor — its marked-or-template position) out + // to the marker — LeftShoulder → LeftArm → LeftForeArm → LeftHand(=marker) — + // distributing the upper-arm/forearm joints along the way so the entire + // arm reaches toward the wrist, not just the hand. + if (const Marker* m = get(MarkerId::LeftWrist)) { + layChain(placed, {"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand"}, m->pos); + ++applied; + } + if (const Marker* m = get(MarkerId::RightWrist)) { + layChain(placed, {"RightShoulder", "RightArm", "RightForeArm", "RightHand"}, m->pos); + ++applied; + } + // Hip sockets: anchor each thigh root (UpLeg) at its marker. Applied BEFORE + // the knee chains so layLeg lays the lower leg from the marked socket. This + // OVERRIDES the hips-carried position above, so an explicit hip marker wins + // (matters for cartoon models where the thighs splay out at odd angles a + // template/pelvis-carry can't capture). A hip marker on its own (no knee) + // still repositions the socket. + if (const Marker* m = get(MarkerId::LeftUpLeg)) { + if (auto* j = findJoint(placed, "LeftUpLeg")) { j->pos = m->pos; ++applied; } + } + if (const Marker* m = get(MarkerId::RightUpLeg)) { + if (auto* j = findJoint(placed, "RightUpLeg")) { j->pos = m->pos; ++applied; } + } + // Legs: the knee marker is the knee (LeftLeg) position. The thigh root + // (UpLeg) is the anchor — it sits at the hip socket (its marked position, + // else carried by the hips marker, else the template fit). Anchor the knee + // at the marker + // and continue the foot below it along the thigh→knee direction (~equal + // length), so the WHOLE leg — thigh root → knee → foot — lays out to follow + // the marked hips + knee instead of leaving the upper leg at its template + // position. (UpLeg→Leg is a 2-joint segment: anchor + tip, so layChain + // would just set the knee; we keep the explicit form to also place the + // extrapolated foot.) + auto layLeg = [&](const char* up, const char* knee, const char* foot, + const std::array& kneePos) { + AutoRig::Joint* hip = findJoint(placed, up); + AutoRig::Joint* kn = findJoint(placed, knee); + if (!hip || !kn) return; + kn->pos = kneePos; + // Foot continues below the knee, same direction as thigh→knee, ~equal len. + if (auto* ft = findJoint(placed, foot)) { + const auto d = std::array{ kneePos[0]-hip->pos[0], + kneePos[1]-hip->pos[1], + kneePos[2]-hip->pos[2] }; + ft->pos = { kneePos[0] + d[0], kneePos[1] + d[1], kneePos[2] + d[2] }; + } + }; + if (const Marker* m = get(MarkerId::LeftKnee)) { layLeg("LeftUpLeg", "LeftLeg", "LeftFoot", m->pos); ++applied; } + if (const Marker* m = get(MarkerId::RightKnee)) { layLeg("RightUpLeg", "RightLeg", "RightFoot", m->pos); ++applied; } + + if (outMarkersApplied) *outMarkersApplied = applied; + return placed; +} + namespace { // Tightly read POSITION floats out of a VertexData (same idiom as @@ -245,6 +459,13 @@ bool appendPositions(Ogre::VertexData* vd, std::vector& out) } // namespace AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) +{ + return rigEntityWithMarkers(entity, /*markers=*/{}, opts); +} + +AutoRig::Report AutoRig::rigEntityWithMarkers(Ogre::Entity* entity, + const std::vector& markers, + const Options& opts) { Report report; report.templateName = templateToString(opts.tmpl); @@ -278,12 +499,16 @@ AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) } report.verticesSampled = vcount; - // Fit the template. - int recentered = 0; + // Fit the template — marker-driven when markers are supplied, else the + // plain proportional fit. + int recentered = 0, markersApplied = 0; const std::vector tmpl = templateJoints(opts.tmpl); - const std::vector placed = - fitTemplate(tmpl, verts.data(), vcount, opts, &recentered); + const std::vector placed = markers.empty() + ? fitTemplate(tmpl, verts.data(), vcount, opts, &recentered) + : fitTemplateWithMarkers(tmpl, verts.data(), vcount, markers, opts, + &recentered, &markersApplied); report.jointsRecentered = recentered; + report.markersApplied = markersApplied; // Build the Ogre skeleton. Bone POSITIONS are parent-relative in Ogre, // so each child's setPosition is its world pos minus its parent's world @@ -349,6 +574,34 @@ AutoRig::Report AutoRig::rigEntity(Ogre::Entity* entity, const Options& opts) return report; } +bool AutoRig::unrigEntity(Ogre::Entity* entity) +{ + if (!entity || !entity->getMesh()) return false; + Ogre::MeshPtr mesh = entity->getMesh(); + if (!mesh->hasSkeleton()) return false; + + // Remember the skeleton resource name so we can free it after detaching. + const std::string skelName = mesh->getSkeletonName(); + + // Drop every bone assignment (shared + per-submesh) so the mesh carries no + // stale weights once it's static again. + mesh->clearBoneAssignments(); + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + if (Ogre::SubMesh* sub = mesh->getSubMesh(si)) + sub->clearBoneAssignments(); + } + + // Detach the skeleton and force the entity back to its static form (mirror + // of the rig path's _notifySkeleton + _initialise(true)). + mesh->_notifySkeleton(Ogre::SkeletonPtr()); + entity->_initialise(true); + + auto& skelMgr = Ogre::SkeletonManager::getSingleton(); + if (!skelName.empty() && skelMgr.resourceExists(skelName)) + skelMgr.remove(skelName); + return true; +} + QString AutoRig::templateToString(Template t) { switch (t) { diff --git a/src/AutoRig.h b/src/AutoRig.h index c892b6fc0..674b76472 100644 --- a/src/AutoRig.h +++ b/src/AutoRig.h @@ -91,6 +91,36 @@ class AutoRig { double slabFraction = 0.06; }; + // Mixamo-style placement markers (humanoid). The user clicks these on the + // mesh surface; the marker positions anchor the corresponding joints and + // the limb chains interpolate between them, so the rig follows the actual + // body proportions instead of a fixed proportional template. Every marker + // is OPTIONAL — an unset marker leaves its joint(s) at the template fit. + enum class MarkerId { + Chin, // anchors Head; spine/neck interpolate Hips→Chin + LeftShoulder, // anchors LeftShoulder (arm-chain attach point) + RightShoulder, // anchors RightShoulder + LeftWrist, // anchors LeftHand (+ LeftArm/LeftForeArm chain) + RightWrist, // anchors RightHand (+ RightArm/RightForeArm chain) + LeftUpLeg, // anchors LeftUpLeg (leg-chain attach / hip socket) + RightUpLeg, // anchors RightUpLeg + LeftKnee, // anchors LeftLeg (+ LeftFoot extrapolated) + RightKnee, // anchors RightLeg (+ RightFoot extrapolated) + Hips, // anchors Hips (pelvis height/centre) + Count + }; + + struct Marker { + MarkerId id = MarkerId::Count; + bool set = false; // false = not placed → joint uses template + std::array pos = {0, 0, 0}; // mesh-local position + }; + + // Stable label for a marker slot (UI + tests). + static QString markerLabel(MarkerId id); + // The ordered marker set the humanoid flow asks for (10 markers). + static std::vector humanoidMarkerOrder(); + struct Report { QString meshName; QString skeletonName; @@ -98,6 +128,7 @@ class AutoRig { int boneCount = 0; int verticesSampled = 0; int jointsRecentered = 0; + int markersApplied = 0; // how many placed markers drove the fit bool applied = false; QString error; }; @@ -112,6 +143,24 @@ class AutoRig { // caller may chain SkinWeights::computeAndApply(entity) for weights. static Report rigEntity(Ogre::Entity* entity, const Options& opts = {}); + // Marker-guided variant: same as rigEntity but anchors the placed markers + // (and interpolates the limb chains between them) before building the + // skeleton. Markers are in mesh-local space. Unset markers fall back to the + // proportional template fit. report.markersApplied counts the placed ones. + static Report rigEntityWithMarkers(Ogre::Entity* entity, + const std::vector& markers, + const Options& opts = {}); + + // Revert a mesh auto-rigged by rigEntity[WithMarkers] back to a static + // (skeleton-less) mesh: clears every submesh's (and the shared) bone + // assignments, detaches the skeleton, re-initialises the entity, and + // removes the `*_autorig` SkeletonManager resource. This is the undo + // primitive for AutoRigCommand — it only makes sense for a mesh that was + // static before rigging (which is the only thing auto-rig accepts), so it + // unconditionally strips the skeleton rather than restoring a prior one. + // Returns true if a skeleton was present and removed. + static bool unrigEntity(Ogre::Entity* entity); + // --- Pure-data core (unit-testable, no Ogre) ------------------------- // The proportional joint graph for a template (positions in [0,1]^3). @@ -129,6 +178,18 @@ class AutoRig { const Options& opts, int* outRecentered = nullptr); + // Marker-driven fit: runs fitTemplate, then anchors the placed markers and + // interpolates the limb chains between them (unset markers keep the + // template fit). `outMarkersApplied` (optional) receives how many set + // markers actually drove a joint. Pure-data — the heart of the marker flow. + static std::vector fitTemplateWithMarkers(const std::vector& tmpl, + const float* vertexPositions, + int vertexCount, + const std::vector& markers, + const Options& opts, + int* outRecentered = nullptr, + int* outMarkersApplied = nullptr); + static QString templateToString(Template t); static Template templateFromString(const QString& s); static QJsonObject reportToJson(const Report& r); diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp index cfbe9abd3..46bbc6126 100644 --- a/src/AutoRigController.cpp +++ b/src/AutoRigController.cpp @@ -3,10 +3,105 @@ #include "SkinWeights.h" #include "SelectionSet.h" #include "SentryReporter.h" +#include "Manager.h" +#include "OgreWidget.h" +#include "SpaceCamera.h" +#include "UndoManager.h" +#include "commands/AutoRigCommand.h" #include #include #include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Read an entity's mesh into tightly-packed world-space triangle vertices. +// (Self-contained — does not depend on Edit Mode's EditableMesh.) Used for the +// marker ray-cast. Returns false if no readable geometry. +bool gatherWorldTriangles(Ogre::Entity* entity, std::vector& outTris) +{ + if (!entity || !entity->getMesh()) return false; + Ogre::MeshPtr mesh = entity->getMesh(); + Ogre::Node* node = entity->getParentSceneNode(); + const Ogre::Affine3 xform = node ? node->_getFullTransform() : Ogre::Affine3::IDENTITY; + + auto readVB = [](Ogre::VertexData* vd, std::vector& pos) { + if (!vd) return; + const auto* pe = vd->vertexDeclaration->findElementBySemantic(Ogre::VES_POSITION); + if (!pe) return; + auto vb = vd->vertexBufferBinding->getBuffer(pe->getSource()); + if (!vb) return; + const size_t stride = vb->getVertexSize(); + auto* base = static_cast(vb->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!base) return; + const size_t start = pos.size(); + pos.resize(start + vd->vertexCount); + for (size_t i = 0; i < vd->vertexCount; ++i) { + float* p = nullptr; + pe->baseVertexPointerToElement(base + i * stride, &p); + pos[start + i] = Ogre::Vector3(p[0], p[1], p[2]); + } + vb->unlock(); + }; + + for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { + Ogre::SubMesh* sub = mesh->getSubMesh(si); + if (!sub) continue; + + std::vector pos; // local-space vertex positions for this submesh + Ogre::VertexData* vd = sub->useSharedVertices ? mesh->sharedVertexData : sub->vertexData; + readVB(vd, pos); + if (pos.empty()) continue; + + Ogre::IndexData* id = sub->indexData; + if (!id || !id->indexBuffer || id->indexCount < 3) continue; + auto ib = id->indexBuffer; + const bool is32 = ib->getType() == Ogre::HardwareIndexBuffer::IT_32BIT; + auto* idxBase = static_cast(ib->lock(Ogre::HardwareBuffer::HBL_READ_ONLY)); + if (!idxBase) continue; + const auto* i32 = reinterpret_cast(idxBase); + const auto* i16 = reinterpret_cast(idxBase); + for (size_t t = 0; t + 2 < id->indexCount; t += 3) { + const uint32_t a = is32 ? i32[t] : i16[t]; + const uint32_t b = is32 ? i32[t+1] : i16[t+1]; + const uint32_t c = is32 ? i32[t+2] : i16[t+2]; + if (a >= pos.size() || b >= pos.size() || c >= pos.size()) continue; + outTris.push_back(xform * pos[a]); + outTris.push_back(xform * pos[b]); + outTris.push_back(xform * pos[c]); + } + ib->unlock(); + } + return !outTris.empty(); +} + +// Möller-Trumbore; returns t>0 on hit else -1. +float rayTri(const Ogre::Vector3& o, const Ogre::Vector3& d, + const Ogre::Vector3& v0, const Ogre::Vector3& v1, const Ogre::Vector3& v2) +{ + const Ogre::Vector3 e1 = v1 - v0, e2 = v2 - v0; + const Ogre::Vector3 p = d.crossProduct(e2); + const float det = e1.dotProduct(p); + if (std::abs(det) < 1e-8f) return -1.0f; + const float inv = 1.0f / det; + const Ogre::Vector3 tv = o - v0; + const float u = tv.dotProduct(p) * inv; + if (u < 0 || u > 1) return -1.0f; + const Ogre::Vector3 q = tv.crossProduct(e1); + const float v = d.dotProduct(q) * inv; + if (v < 0 || u + v > 1) return -1.0f; + const float t = e2.dotProduct(q) * inv; + return t > 1e-6f ? t : -1.0f; +} + +} // namespace AutoRigController* AutoRigController::m_pSingleton = nullptr; @@ -91,20 +186,30 @@ QVariantMap AutoRigController::autoRigSelected(const QString& templateName, .arg(QString::fromStdString(entity->getName()), AutoRig::templateToString(opts.tmpl))); + // Pre-check here so a non-static mesh fails cleanly WITHOUT leaving a + // no-op entry on the undo stack (QUndoStack::push runs redo()). + if (entity->getMesh()->hasSkeleton()) { + const auto msg = QStringLiteral( + "Mesh already has a skeleton — auto-rig only applies to unrigged " + "(static) meshes."); + emit error(msg); + result["applied"] = false; + result["error"] = msg; + return result; + } + m_busy = true; emit busyChanged(); AutoRig::Report report; bool skinned = false; try { - report = AutoRig::rigEntity(entity, opts); - if (report.applied && alsoSkin) { - const auto sw = SkinWeights::computeAndApply(entity, {}); - skinned = sw.applied; - if (!sw.applied) - report.error = QStringLiteral("rigged, but skinning failed: %1") - .arg(sw.error); - } + // Run through an undo command so rig (+ optional skin) reverts with + // Ctrl+Z. push() executes redo() synchronously; read back the report. + auto* cmd = new AutoRigCommand(entity->getName(), opts, {}, alsoSkin); + UndoManager::getSingleton()->push(cmd); + report = cmd->report(); + skinned = cmd->skinned(); } catch (const Ogre::Exception& e) { m_busy = false; emit busyChanged(); @@ -135,3 +240,286 @@ QVariantMap AutoRigController::autoRigSelected(const QString& templateName, return result; } + +// ============================ Marker placement ============================ + +Ogre::Entity* AutoRigController::selectedRiggableEntity() const +{ + auto* sel = SelectionSet::getSingleton(); + const auto ents = sel ? sel->getResolvedEntities() : QList{}; + if (ents.isEmpty()) return nullptr; + Ogre::Entity* e = ents.first(); + if (!e || !e->getMesh() || e->getMesh()->getSkeleton() != nullptr) return nullptr; + return e; +} + +int AutoRigController::markerCount() const +{ + int n = 0; + for (const auto& m : m_markers) if (m.set) ++n; + return n; +} + +int AutoRigController::markerTotal() const +{ + return static_cast(m_markerOrder.size()); +} + +QString AutoRigController::currentMarkerLabel() const +{ + // The next unset marker in order. + for (auto id : m_markerOrder) { + bool placed = false; + for (const auto& m : m_markers) if (m.id == id && m.set) { placed = true; break; } + if (!placed) return AutoRig::markerLabel(id); + } + return QString(); // all placed +} + +bool AutoRigController::beginMarkerPlacement(const QString& upAxis) +{ + Ogre::Entity* e = selectedRiggableEntity(); + if (!e) { emit error(QStringLiteral("Select a static (unrigged) mesh first.")); return false; } + + const QString ax = upAxis.trimmed().toLower(); + m_upAxis = (ax == "x") ? 0 : (ax == "z") ? 2 : 1; + m_markerEntityName = e->getName(); + m_markerOrder = AutoRig::humanoidMarkerOrder(); + m_markers.clear(); + clearMarkerOverlays(); + m_markerMode = true; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("marker placement begin entity=%1") + .arg(QString::fromStdString(m_markerEntityName))); + emit markerModeChanged(); + emit markerCountChanged(); + return true; +} + +void AutoRigController::cancelMarkerPlacement() +{ + if (!m_markerMode) return; + m_markerMode = false; + m_markers.clear(); + m_markerOrder.clear(); + clearMarkerOverlays(); + emit markerModeChanged(); + emit markerCountChanged(); +} + +void AutoRigController::skipCurrentMarker() +{ + if (!m_markerMode) return; + // Record an explicit "skipped" placeholder (set=false but consumed) by + // advancing past the current marker: insert an unset marker so current + // MarkerLabel moves on. + const QString cur = currentMarkerLabel(); + if (cur.isEmpty()) return; + for (auto id : m_markerOrder) { + if (AutoRig::markerLabel(id) != cur) continue; + AutoRig::Marker m; m.id = id; m.set = false; + m_markers.push_back(m); // unset → keeps template, but consumes the slot + break; + } + emit markerCountChanged(); +} + +void AutoRigController::undoLastMarker() +{ + if (!m_markerMode || m_markers.empty()) return; + m_markers.pop_back(); + refreshMarkerOverlays(); + emit markerCountChanged(); +} + +bool AutoRigController::handleMarkerClick(OgreWidget* widget, const QPoint& screenPos) +{ + if (!m_markerMode || !widget) return false; + const QString cur = currentMarkerLabel(); + if (cur.isEmpty()) return true; // all placed; consume click but do nothing + + Ogre::Entity* e = selectedRiggableEntity(); + if (!e || e->getName() != m_markerEntityName) { + // Selection changed out from under us — abort marker mode. + cancelMarkerPlacement(); + return false; + } + + auto* spaceCam = widget->getSpaceCamera(); + auto* cam = spaceCam ? spaceCam->getCamera() : nullptr; + if (!cam) return true; + int vw = 0, vh = 0; + widget->pixelSizeForCameraPicking(vw, vh); + if (vw <= 0 || vh <= 0) return true; + + const Ogre::Real nx = static_cast(screenPos.x()) / vw; + const Ogre::Real ny = static_cast(screenPos.y()) / vh; + const Ogre::Ray ray = cam->getCameraToViewportRay(nx, ny); + + std::vector tris; + if (!gatherWorldTriangles(e, tris)) return true; + + float bestT = std::numeric_limits::infinity(); + Ogre::Vector3 hit; + bool found = false; + for (size_t i = 0; i + 2 < tris.size(); i += 3) { + const float t = rayTri(ray.getOrigin(), ray.getDirection(), tris[i], tris[i+1], tris[i+2]); + if (t > 0 && t < bestT) { bestT = t; hit = ray.getOrigin() + ray.getDirection() * t; found = true; } + } + if (!found) return true; // missed the mesh — consume (don't select something else) + + // Store the marker in MESH-LOCAL space (the fit works in local coords). + Ogre::Node* node = e->getParentSceneNode(); + const Ogre::Vector3 local = node + ? node->_getFullTransform().inverse() * hit : hit; + + // Find which MarkerId is current and record it. + for (auto id : m_markerOrder) { + if (AutoRig::markerLabel(id) != cur) continue; + AutoRig::Marker m; + m.id = id; m.set = true; + m.pos = { local.x, local.y, local.z }; + m_markers.push_back(m); + break; + } + refreshMarkerOverlays(); + emit markerPlaced(cur); + emit markerCountChanged(); + return true; +} + +QVariantMap AutoRigController::commitMarkerRig(bool alsoSkin) +{ + QVariantMap result; + Ogre::Entity* entity = selectedRiggableEntity(); + if (!entity || entity->getName() != m_markerEntityName) { + const auto msg = QStringLiteral("Selected mesh is no longer valid for rigging."); + emit error(msg); result["applied"] = false; result["error"] = msg; + cancelMarkerPlacement(); + return result; + } + + // Collect only the SET markers (placed ones); skipped/unset fall back. + std::vector placed; + for (const auto& m : m_markers) if (m.set) placed.push_back(m); + + AutoRig::Options opts; + opts.tmpl = AutoRig::Template::Humanoid; // markers are a humanoid concept + opts.upAxis = m_upAxis; + + SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.auto_rig"), + QStringLiteral("marker rig commit entity=%1 markers=%2") + .arg(QString::fromStdString(m_markerEntityName)).arg(placed.size())); + + m_busy = true; emit busyChanged(); + AutoRig::Report report; + bool skinned = false; + try { + // Undoable, same as autoRigSelected — markers ride along in the command. + auto* cmd = new AutoRigCommand(entity->getName(), opts, placed, alsoSkin); + UndoManager::getSingleton()->push(cmd); + report = cmd->report(); + skinned = cmd->skinned(); + } catch (const Ogre::Exception& ex) { + m_busy = false; emit busyChanged(); + const auto msg = QString::fromStdString(ex.getFullDescription()); + emit error(QStringLiteral("Ogre error: %1").arg(msg)); + result["applied"] = false; result["error"] = msg; + return result; + } + + // Leave marker mode (clears overlays) regardless of outcome. + m_markerMode = false; + m_markers.clear(); + m_markerOrder.clear(); + clearMarkerOverlays(); + emit markerModeChanged(); + + m_busy = false; emit busyChanged(); + emit selectionChanged(); + + result["applied"] = report.applied; + result["meshName"] = report.meshName; + result["boneCount"] = report.boneCount; + result["markersApplied"] = report.markersApplied; + result["skinned"] = skinned; + if (!report.error.isEmpty()) result["error"] = report.error; + + if (report.applied) emit rigged(result); + else emit error(report.error.isEmpty() ? QStringLiteral("Auto-rig failed") : report.error); + return result; +} + +void AutoRigController::clearMarkerOverlays() +{ + auto* mgr = Manager::getSingletonPtr(); + Ogre::SceneManager* scene = mgr ? mgr->getSceneMgr() : nullptr; + for (Ogre::SceneNode* n : m_markerNodes) { + if (!n) continue; + n->removeAndDestroyAllChildren(); + if (scene) { + // Destroy attached entities then the node. + auto objs = n->getAttachedObjects(); + for (auto* o : objs) scene->destroyMovableObject(o); + scene->destroySceneNode(n); + } + } + m_markerNodes.clear(); +} + +void AutoRigController::refreshMarkerOverlays() +{ + clearMarkerOverlays(); + auto* mgr = Manager::getSingletonPtr(); + Ogre::SceneManager* scene = mgr ? mgr->getSceneMgr() : nullptr; + Ogre::Entity* e = selectedRiggableEntity(); + if (!scene || !e) return; + Ogre::Node* node = e->getParentSceneNode(); + + // Small unit sphere mesh + bright unlit material, created once. + const std::string meshName = "__AutoRigMarkerSphere__"; + if (!Ogre::MeshManager::getSingleton().resourceExists(meshName)) { + Ogre::MeshManager::getSingleton().createManual(meshName, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + // Use Ogre's built-in sphere via the prefab if manual gen is unavailable. + } + const std::string matName = "__AutoRigMarkerMat__"; + auto& mm = Ogre::MaterialManager::getSingleton(); + if (!mm.resourceExists(matName)) { + auto mat = mm.create(matName, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + auto* pass = mat->getTechnique(0)->getPass(0); + pass->setLightingEnabled(false); + pass->setDiffuse(Ogre::ColourValue(1.0f, 0.85f, 0.1f, 1.0f)); + pass->setAmbient(Ogre::ColourValue(1.0f, 0.85f, 0.1f, 1.0f)); + pass->setDepthCheckEnabled(false); // always visible over the mesh + } + + // Marker world size ~3% of the mesh's bounding radius. + const Ogre::Real r = e->getBoundingRadius() * 0.03f; + + for (const auto& m : m_markers) { + if (!m.set) continue; + const Ogre::Vector3 localPos( + static_cast(m.pos[0]), + static_cast(m.pos[1]), + static_cast(m.pos[2])); + const Ogre::Vector3 worldPos = node ? node->_getFullTransform() * localPos : localPos; + + Ogre::SceneNode* sn = scene->getRootSceneNode()->createChildSceneNode(); + Ogre::Entity* sphere = nullptr; + try { + sphere = scene->createEntity(Ogre::SceneManager::PT_SPHERE); + } catch (...) { sphere = nullptr; } + if (sphere) { + sphere->setMaterialName(matName); + sphere->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY - 1); + sn->attachObject(sphere); + // Ogre's PT_SPHERE has radius 100; scale to the desired world radius. + const Ogre::Real s = (r > 1e-4f ? r : 0.02f) / 100.0f; + sn->setScale(s, s, s); + } + sn->setPosition(worldPos); + m_markerNodes.push_back(sn); + } +} diff --git a/src/AutoRigController.h b/src/AutoRigController.h index 3a1ecfc9f..b7526c8ac 100644 --- a/src/AutoRigController.h +++ b/src/AutoRigController.h @@ -4,11 +4,23 @@ #include #include #include +#include +#include + +#include "AutoRig.h" + +class OgreWidget; +namespace Ogre { class Entity; class SceneNode; } // QML-facing singleton for native auto-rigging (issue #407). // Wraps `AutoRig::rigEntity` (+ optional `SkinWeights::computeAndApply`) // and exposes selection state so the Animation-Mode button can disable // itself when the selection isn't a riggable static mesh. +// +// It also drives the Mixamo-style MARKER placement flow: the user enters +// marker mode, clicks the 10 humanoid markers on the mesh in the viewport +// (routed in via TransformOperator), and commits — the markers anchor the +// matching joints and the limb chains interpolate between them. class AutoRigController : public QObject { Q_OBJECT @@ -20,6 +32,11 @@ class AutoRigController : public QObject // and empty selections disable the button. Q_PROPERTY(bool hasRiggableSelection READ hasRiggableSelection NOTIFY selectionChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + // Marker-placement session state (for the QML guided UX). + Q_PROPERTY(bool markerMode READ markerMode NOTIFY markerModeChanged) + Q_PROPERTY(int markerCount READ markerCount NOTIFY markerCountChanged) + Q_PROPERTY(int markerTotal READ markerTotal NOTIFY markerModeChanged) + Q_PROPERTY(QString currentMarkerLabel READ currentMarkerLabel NOTIFY markerCountChanged) public: static AutoRigController* instance(); @@ -38,18 +55,58 @@ class AutoRigController : public QObject const QString& upAxis, bool alsoSkin); + // ---- Marker placement (Mixamo-style) ------------------------------- + bool markerMode() const { return m_markerMode; } + int markerCount() const; // markers placed so far + int markerTotal() const; // total expected (10 for humanoid) + QString currentMarkerLabel() const; // label of the next marker to place + + /// Enter marker mode for the selected static mesh. Subsequent viewport + /// clicks place the markers (chin, L/R shoulder, L/R wrist, L/R hip, + /// L/R knee, hips/pelvis in order). + Q_INVOKABLE bool beginMarkerPlacement(const QString& upAxis); + /// Leave marker mode, discarding any placed markers + their overlays. + Q_INVOKABLE void cancelMarkerPlacement(); + /// Skip the current marker (leaves that joint at the template fit). + Q_INVOKABLE void skipCurrentMarker(); + /// Remove the last placed marker (undo one click). + Q_INVOKABLE void undoLastMarker(); + /// Build the rig from the placed markers (+ optional skin). Returns a + /// QVariantMap like autoRigSelected. + Q_INVOKABLE QVariantMap commitMarkerRig(bool alsoSkin); + + /// Called by TransformOperator when a viewport click happens while marker + /// mode is active. Ray-casts to the mesh surface and records the marker. + /// Returns true if the click was consumed (so the operator skips select). + bool handleMarkerClick(OgreWidget* widget, const QPoint& screenPos); + signals: void selectionChanged(); void busyChanged(); void rigged(const QVariantMap& report); void error(const QString& message); + void markerModeChanged(); + void markerCountChanged(); + void markerPlaced(const QString& label); private: AutoRigController(); ~AutoRigController() override = default; + Ogre::Entity* selectedRiggableEntity() const; + void clearMarkerOverlays(); + void refreshMarkerOverlays(); + static AutoRigController* m_pSingleton; bool m_busy = false; + + // Marker session. + bool m_markerMode = false; + int m_upAxis = 1; // resolved at begin + std::vector m_markerOrder; // the 10, in click order + std::vector m_markers; // accumulated (set flag) + std::vector m_markerNodes; // viewport sphere overlays + std::string m_markerEntityName; // entity being marked }; #endif // AUTO_RIG_CONTROLLER_H diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp index 5edfa3957..8787f7b8f 100644 --- a/src/AutoRig_test.cpp +++ b/src/AutoRig_test.cpp @@ -155,3 +155,275 @@ TEST(AutoRigCore, ReportSerialization) fail.error = "boom"; EXPECT_TRUE(AutoRig::reportToText(fail).contains("boom")); } + +// ---- Marker-driven fit (#407 Mixamo-style) ------------------------------ + +namespace { +// Distance between two joint positions. +double jdist(const AutoRig::Joint& a, const AutoRig::Joint& b) +{ + double dx = a.pos[0] - b.pos[0]; + double dy = a.pos[1] - b.pos[1]; + double dz = a.pos[2] - b.pos[2]; + return std::sqrt(dx * dx + dy * dy + dz * dz); +} +int jindex(const std::vector& js, const QString& name) +{ + for (int i = 0; i < static_cast(js.size()); ++i) + if (js[i].name == name) return i; + return -1; +} +} // namespace + +TEST(AutoRigMarkers, OrderAndLabelsAreStable) +{ + const auto order = AutoRig::humanoidMarkerOrder(); + ASSERT_EQ(order.size(), 6u); + EXPECT_EQ(order[0], AutoRig::MarkerId::Chin); + EXPECT_EQ(order[5], AutoRig::MarkerId::Hips); + for (auto id : order) + EXPECT_FALSE(AutoRig::markerLabel(id).isEmpty()); +} + +TEST(AutoRigMarkers, EmptyMarkersMatchPlainFit) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; // +Y up, humanoid + + int recenterA = 0, recenterB = 0, applied = -1; + auto plain = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + opts, &recenterA); + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {}, opts, &recenterB, &applied); + ASSERT_EQ(plain.size(), marked.size()); + EXPECT_EQ(applied, 0); + for (size_t i = 0; i < plain.size(); ++i) + EXPECT_LT(jdist(plain[i], marked[i]), 1e-6) << "joint " << i; +} + +TEST(AutoRigMarkers, WristMarkerLaysWholeArmChainTowardIt) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + // Skip if this template doesn't expose the named arm chain. + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iShoulder = jindex(base, "LeftShoulder"); + const int iArm = jindex(base, "LeftArm"); + const int iFore = jindex(base, "LeftForeArm"); + const int iHand = jindex(base, "LeftHand"); + if (iShoulder < 0 || iArm < 0 || iFore < 0 || iHand < 0) + GTEST_SKIP() << "no left-arm chain"; + + AutoRig::Marker wrist; + wrist.id = AutoRig::MarkerId::LeftWrist; + wrist.set = true; + wrist.pos = {1.25, 1.55, 0.10}; // far out from the body + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {wrist}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + + // Hand lands exactly on the marker. + EXPECT_LT(std::abs(marked[iHand].pos[0] - wrist.pos[0]), 1e-6); + EXPECT_LT(std::abs(marked[iHand].pos[1] - wrist.pos[1]), 1e-6); + EXPECT_LT(std::abs(marked[iHand].pos[2] - wrist.pos[2]), 1e-6); + + // Shoulder (the anchor) is unchanged from the template fit. + EXPECT_LT(jdist(marked[iShoulder], base[iShoulder]), 1e-6); + + // The intermediate joints lie evenly on the shoulder→hand segment: + // LeftArm at 1/3, LeftForeArm at 2/3. + const auto& a = marked[iShoulder].pos; + for (int k = 0; k < 3; ++k) { + const double arm13 = a[k] + (wrist.pos[k] - a[k]) * (1.0 / 3.0); + const double fore23 = a[k] + (wrist.pos[k] - a[k]) * (2.0 / 3.0); + EXPECT_LT(std::abs(marked[iArm].pos[k] - arm13), 1e-6) << "arm axis " << k; + EXPECT_LT(std::abs(marked[iFore].pos[k] - fore23), 1e-6) << "fore axis " << k; + } + + // The upper arm (LeftArm) actually moved OUT toward the wrist — the bug we + // fixed was that it stayed at its tucked template x while only the wrist moved. + EXPECT_GT(std::abs(marked[iArm].pos[0]), std::abs(base[iArm].pos[0])); +} + +TEST(AutoRigMarkers, HipsMarkerAnchorsPelvisOnly) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + if (iHips < 0) GTEST_SKIP() << "no Hips joint"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; + hips.set = true; + hips.pos = {0.05, 0.9, 0.0}; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {hips}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + EXPECT_LT(jdist(marked[iHips], AutoRig::Joint{"", -1, hips.pos}), 1e-6); +} + +TEST(AutoRigMarkers, OrderHasTenWithAttachPointsBeforeTips) +{ + const auto order = AutoRig::humanoidMarkerOrder(); + ASSERT_EQ(order.size(), 10u); + auto pos = [&](AutoRig::MarkerId id) { + for (size_t i = 0; i < order.size(); ++i) if (order[i] == id) return (int)i; + return -1; + }; + // Attach points precede their tips: shoulder→wrist, hip→knee. + EXPECT_GE(pos(AutoRig::MarkerId::LeftShoulder), 0); + EXPECT_GE(pos(AutoRig::MarkerId::LeftUpLeg), 0); + EXPECT_LT(pos(AutoRig::MarkerId::LeftShoulder), pos(AutoRig::MarkerId::LeftWrist)); + EXPECT_LT(pos(AutoRig::MarkerId::RightShoulder), pos(AutoRig::MarkerId::RightWrist)); + EXPECT_LT(pos(AutoRig::MarkerId::LeftUpLeg), pos(AutoRig::MarkerId::LeftKnee)); + EXPECT_LT(pos(AutoRig::MarkerId::RightUpLeg), pos(AutoRig::MarkerId::RightKnee)); + EXPECT_FALSE(AutoRig::markerLabel(AutoRig::MarkerId::LeftUpLeg).isEmpty()); + EXPECT_FALSE(AutoRig::markerLabel(AutoRig::MarkerId::LeftShoulder).isEmpty()); +} + +TEST(AutoRigMarkers, ChinAndHipsLaySpineBetweenThem) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + const int iSpine = jindex(base, "Spine"); + const int iChest = jindex(base, "Chest"); + const int iNeck = jindex(base, "Neck"); + const int iHead = jindex(base, "Head"); + if (iHips < 0 || iSpine < 0 || iChest < 0 || iNeck < 0 || iHead < 0) + GTEST_SKIP() << "no spine chain"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; hips.set = true; hips.pos = {0.0, 0.80, 0.0}; + AutoRig::Marker chin; + chin.id = AutoRig::MarkerId::Chin; chin.set = true; chin.pos = {0.0, 2.20, 0.0}; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {hips, chin}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + + // Head=chin, Hips=hips, and Spine/Chest/Neck distributed evenly on the + // segment: last = 3 spine joints + Head = 4 steps, so Spine@1/4, Chest@2/4, + // Neck@3/4 between hips and chin. + EXPECT_LT(jdist(marked[iHead], AutoRig::Joint{"", -1, chin.pos}), 1e-6); + EXPECT_LT(jdist(marked[iHips], AutoRig::Joint{"", -1, hips.pos}), 1e-6); + const auto& a = hips.pos; + auto onSeg = [&](double t) { + return std::array{ a[0]+(chin.pos[0]-a[0])*t, + a[1]+(chin.pos[1]-a[1])*t, + a[2]+(chin.pos[2]-a[2])*t }; + }; + EXPECT_LT(jdist(marked[iSpine], AutoRig::Joint{"", -1, onSeg(1.0/4)}), 1e-6); + EXPECT_LT(jdist(marked[iChest], AutoRig::Joint{"", -1, onSeg(2.0/4)}), 1e-6); + EXPECT_LT(jdist(marked[iNeck], AutoRig::Joint{"", -1, onSeg(3.0/4)}), 1e-6); +} + +TEST(AutoRigMarkers, HipsCarriesThighRootsAndKneeLaysLowerLeg) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + const int iUpLeg = jindex(base, "LeftUpLeg"); + const int iKnee = jindex(base, "LeftLeg"); + const int iFoot = jindex(base, "LeftFoot"); + if (iHips < 0 || iUpLeg < 0 || iKnee < 0 || iFoot < 0) + GTEST_SKIP() << "no left-leg chain"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; + hips.set = true; + hips.pos = {0.0, 1.05, 0.0}; + AutoRig::Marker knee; + knee.id = AutoRig::MarkerId::LeftKnee; + knee.set = true; + knee.pos = {0.40, 0.55, 0.05}; + + // Expected thigh-root shift = the hips delta (UpLeg is carried with Hips). + const auto& bH = base[iHips].pos; + const std::array d = { hips.pos[0]-bH[0], hips.pos[1]-bH[1], hips.pos[2]-bH[2] }; + const std::array expUpLeg = { base[iUpLeg].pos[0]+d[0], + base[iUpLeg].pos[1]+d[1], + base[iUpLeg].pos[2]+d[2] }; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {hips, knee}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + + // Thigh root tracked the hips marker (didn't stay at its template pos). + EXPECT_LT(jdist(marked[iUpLeg], AutoRig::Joint{"", -1, expUpLeg}), 1e-6); + // Knee landed on its marker. + EXPECT_LT(jdist(marked[iKnee], AutoRig::Joint{"", -1, knee.pos}), 1e-6); + // Foot continues below the knee along thigh→knee (knee + (knee - upLeg)). + const auto& U = marked[iUpLeg].pos; + const std::array expFoot = { knee.pos[0] + (knee.pos[0]-U[0]), + knee.pos[1] + (knee.pos[1]-U[1]), + knee.pos[2] + (knee.pos[2]-U[2]) }; + EXPECT_LT(jdist(marked[iFoot], AutoRig::Joint{"", -1, expFoot}), 1e-6); +} + +TEST(AutoRigMarkers, ShoulderMarkerAnchorsAttachAndArmLaysFromIt) +{ + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iShoulder = jindex(base, "LeftShoulder"); + const int iArm = jindex(base, "LeftArm"); + const int iHand = jindex(base, "LeftHand"); + if (iShoulder < 0 || iArm < 0 || iHand < 0) GTEST_SKIP() << "no left-arm chain"; + + AutoRig::Marker shoulder; + shoulder.id = AutoRig::MarkerId::LeftShoulder; + shoulder.set = true; + shoulder.pos = {0.35, 1.60, 0.0}; + AutoRig::Marker wrist; + wrist.id = AutoRig::MarkerId::LeftWrist; + wrist.set = true; + wrist.pos = {1.30, 1.55, 0.10}; + + int applied = 0; + auto marked = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), + {shoulder, wrist}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + + // Shoulder lands on its marker; the arm chain lays from THAT point, so + // LeftArm = lerp(shoulderMarker, wristMarker, 1/3). + EXPECT_LT(jdist(marked[iShoulder], AutoRig::Joint{"", -1, shoulder.pos}), 1e-6); + for (int k = 0; k < 3; ++k) { + const double arm13 = + shoulder.pos[k] + (wrist.pos[k] - shoulder.pos[k]) * (1.0 / 3.0); + EXPECT_LT(std::abs(marked[iArm].pos[k] - arm13), 1e-6) << "axis " << k; + } + EXPECT_LT(jdist(marked[iHand], AutoRig::Joint{"", -1, wrist.pos}), 1e-6); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 835eea086..328be955e 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -75,6 +75,7 @@ commands/NodeAnimCommands.cpp commands/PoseLibraryCommands.cpp commands/SkeletonResolver.cpp commands/ComputeSkinWeightsCommand.cpp +commands/AutoRigCommand.cpp BoneDragRelease.cpp PropertiesPanelController.cpp SceneTreeModel.cpp diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 4811cdca4..e5e9f9528 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -732,6 +732,31 @@ bool PropertiesPanelController::hasAnimations() const return false; } +QVariantList PropertiesPanelController::skeletonData() const +{ + QVariantList result; + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + { + if (!ent || !ent->hasSkeleton()) continue; // skeleton viz, no anim gate + + QVariantMap entry; + entry["entity"] = QString::fromStdString(ent->getName()); + entry["showSkeleton"] = mAnimationWidget ? mAnimationWidget->isSkeletonDebugActive(ent) : false; + entry["showWeights"] = mAnimationWidget ? mAnimationWidget->isBoneWeightsShown(ent) : false; + result.append(entry); + } + return result; +} + +bool PropertiesPanelController::hasSkeletonSelection() const +{ + auto entities = SelectionSet::getSingleton()->getResolvedEntities(); + for (Ogre::Entity* ent : entities) + if (ent && ent->hasSkeleton()) return true; + return false; +} + QVariantList PropertiesPanelController::animationData() const { QVariantList result; diff --git a/src/PropertiesPanelController.h b/src/PropertiesPanelController.h index b4dfccfad..9b97cd883 100644 --- a/src/PropertiesPanelController.h +++ b/src/PropertiesPanelController.h @@ -215,6 +215,18 @@ class PropertiesPanelController : public QObject Q_INVOKABLE bool reparentNode(const QString& nodeName, const QString& newParentName); void setAnimationWidget(class AnimationWidget* widget) { mAnimationWidget = widget; } + // Skeleton (bone/skeleton viz — independent of animation clips). + // Returns one entry per selected entity that HAS a skeleton, regardless of + // whether it has any animation states. Each entry: { entity, showSkeleton, + // showWeights }. This is the data behind the "Skeleton" inspector section, + // which must surface for skinned-but-non-animated meshes (e.g. a freshly + // auto-rigged static mesh) — unlike animationData() which skips entities + // with no animation clips. + Q_INVOKABLE QVariantList skeletonData() const; + /// True when the first resolved selection has a skeleton. Drives the + /// "Skeleton" section's visibility. + Q_INVOKABLE bool hasSkeletonSelection() const; + // Animation Q_INVOKABLE QVariantList animationData() const; // grouped per entity Q_INVOKABLE void toggleAnimationEnabled(const QString& entityName, const QString& animName, bool enabled); diff --git a/src/TransformOperator.cpp b/src/TransformOperator.cpp index 9f3c86281..a7dac21f7 100755 --- a/src/TransformOperator.cpp +++ b/src/TransformOperator.cpp @@ -25,6 +25,7 @@ #include "commands/BoneTransformCommand.h" #include "BoneDragRelease.h" #include "EditModeController.h" +#include "AutoRigController.h" #include "TexturePaintController.h" #include "AnimationControlController.h" #include "PropertiesPanelController.h" @@ -994,6 +995,16 @@ void TransformOperator::mousePressEvent(QMouseEvent *e) { if (e->button()==Qt::LeftButton) { + // Auto-rig marker placement (Mixamo-style) is active: left-click drops + // the next marker on the mesh surface. Highest priority — like the + // knife session, nothing else (selection/transform) fires while placing + // markers. Dismissed via the dialog's Cancel/Commit. + if (AutoRigController::instance()->markerMode()) + { + AutoRigController::instance()->handleMarkerClick(m_pActiveWidget, e->pos()); + return; + } + auto* editCtrl = EditModeController::instance(); // Knife session is active: left-click adds a cut point at the diff --git a/src/commands/AutoRigCommand.cpp b/src/commands/AutoRigCommand.cpp new file mode 100644 index 000000000..acbdf7cf1 --- /dev/null +++ b/src/commands/AutoRigCommand.cpp @@ -0,0 +1,61 @@ +#include "commands/AutoRigCommand.h" +#include "Manager.h" +#include "SkinWeights.h" + +#include +#include + +AutoRigCommand::AutoRigCommand(std::string entityName, + AutoRig::Options opts, + std::vector markers, + bool alsoSkin, + QUndoCommand* parent) + : QUndoCommand(parent) + , mEntityName(std::move(entityName)) + , mOpts(opts) + , mMarkers(std::move(markers)) + , mAlsoSkin(alsoSkin) +{ + setText(mMarkers.empty() ? QStringLiteral("Auto-Rig") + : QStringLiteral("Auto-Rig from Markers")); +} + +Ogre::Entity* AutoRigCommand::resolveEntity() const +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return nullptr; + for (Ogre::Entity* e : mgr->getEntities()) { + if (e && e->getMovableType() == "Entity" && e->getName() == mEntityName) + return e; + } + return nullptr; +} + +void AutoRigCommand::redo() +{ + Ogre::Entity* entity = resolveEntity(); + if (!entity) { + mReport.applied = false; + mReport.error = QStringLiteral("Entity no longer in scene."); + return; + } + + // unrigEntity (run on undo) leaves a clean static mesh, so re-running the + // rig on a redo is idempotent — no special first-vs-replay handling needed. + mSkinned = false; + mReport = AutoRig::rigEntityWithMarkers(entity, mMarkers, mOpts); + if (mReport.applied && mAlsoSkin) { + const auto sw = SkinWeights::computeAndApply(entity, {}); + mSkinned = sw.applied; + if (!sw.applied) + mReport.error = QStringLiteral("rigged, but skinning failed: %1") + .arg(sw.error); + } +} + +void AutoRigCommand::undo() +{ + if (!mReport.applied) return; // nothing was attached + if (Ogre::Entity* entity = resolveEntity()) + AutoRig::unrigEntity(entity); +} diff --git a/src/commands/AutoRigCommand.h b/src/commands/AutoRigCommand.h new file mode 100644 index 000000000..940dc1980 --- /dev/null +++ b/src/commands/AutoRigCommand.h @@ -0,0 +1,61 @@ +#ifndef AUTO_RIG_COMMAND_H +#define AUTO_RIG_COMMAND_H + +#include +#include + +#include +#include + +#include "AutoRig.h" + +namespace Ogre { class Entity; } + +/** + * Undoable wrapper around `AutoRig::rigEntity[WithMarkers]` (+ optional + * `SkinWeights::computeAndApply`) — issue #407 follow-up. + * + * Auto-rig only ever runs on a STATIC (skeleton-less) mesh, so the undo is + * unambiguous: strip the freshly-attached skeleton and revert the entity to + * its static form (`AutoRig::unrigEntity`). There is no prior skeleton/weights + * to snapshot — the "before" state is simply "no skeleton". + * + * `redo()` runs the rig on its first invocation (and again on later redos — + * `unrigEntity` leaves a clean static mesh, so re-rigging is idempotent); + * `undo()` strips the rig. When `alsoSkin` is set, the skin pass runs inside + * the same command (not as a child) so a single Ctrl+Z reverts rig + skin + * together. The captured report lets the controller surface bone/marker counts + * to the UI after pushing the command. + * + * Targets the entity by name so it survives scene rebuilds, like the other + * entity-scoped commands. + */ +class AutoRigCommand : public QUndoCommand +{ +public: + AutoRigCommand(std::string entityName, + AutoRig::Options opts, + std::vector markers, + bool alsoSkin, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + const AutoRig::Report& report() const { return mReport; } + bool applied() const { return mReport.applied; } + bool skinned() const { return mSkinned; } + +private: + Ogre::Entity* resolveEntity() const; + + std::string mEntityName; + AutoRig::Options mOpts; + std::vector mMarkers; + bool mAlsoSkin = false; + + AutoRig::Report mReport; + bool mSkinned = false; +}; + +#endif // AUTO_RIG_COMMAND_H diff --git a/src/commands/AutoRigCommand_test.cpp b/src/commands/AutoRigCommand_test.cpp new file mode 100644 index 000000000..41915620f --- /dev/null +++ b/src/commands/AutoRigCommand_test.cpp @@ -0,0 +1,101 @@ +#include + +#include + +#include "commands/AutoRigCommand.h" +#include "AutoRig.h" +#include "Manager.h" + +// These tests exercise the no-Ogre / error-report branches of AutoRigCommand +// (the ones that need NO scene and NO display): +// +// * ctor / setText contract (plain "Auto-Rig" vs "Auto-Rig from Markers"), +// * report()/applied()/skinned() accessors before redo(), +// * redo() against an unresolvable entity name → error branch (applied==false), +// * undo() before any successful redo → strict no-op (guarded on applied). +// +// resolveEntity() returns nullptr when Manager::getSingletonPtr() is null OR no +// entity matches, so a bogus name reliably drives the error branch. The actual +// rig + skin attach/detach round-trip needs a real mesh and is covered by an +// Ogre-gated layer on CI. + +namespace { +const std::string kBogusEntity = + "__qtmesh_nonexistent_entity_for_autorig_test__"; + +AutoRig::Options humanoidOpts() { + AutoRig::Options o; + o.tmpl = AutoRig::Template::Humanoid; + o.upAxis = 1; + return o; +} +} // namespace + +// ---- ctor / text() ------------------------------------------------------- + +TEST(AutoRigCommandTest, CtorSetsPlainTextWithoutMarkers) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, /*alsoSkin=*/false); + EXPECT_EQ(cmd.text(), QStringLiteral("Auto-Rig")); +} + +TEST(AutoRigCommandTest, CtorSetsMarkerTextWithMarkers) { + AutoRig::Marker m; + m.id = AutoRig::MarkerId::Hips; + m.set = true; + m.pos = {0, 0, 0}; + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {m}, /*alsoSkin=*/true); + EXPECT_EQ(cmd.text(), QStringLiteral("Auto-Rig from Markers")); +} + +// ---- initial accessor state ---------------------------------------------- + +TEST(AutoRigCommandTest, ReportInitiallyNotApplied) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + EXPECT_FALSE(cmd.applied()); + EXPECT_FALSE(cmd.report().applied); + EXPECT_FALSE(cmd.skinned()); + EXPECT_TRUE(cmd.report().error.isEmpty()); + EXPECT_EQ(cmd.report().boneCount, 0); + EXPECT_EQ(cmd.report().markersApplied, 0); +} + +// ---- redo() on an unresolvable entity → error branch --------------------- + +TEST(AutoRigCommandTest, RedoOnBogusEntitySetsErrorReport) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + cmd.redo(); + EXPECT_FALSE(cmd.applied()); + EXPECT_EQ(cmd.report().error, QStringLiteral("Entity no longer in scene.")); +} + +TEST(AutoRigCommandTest, RedoWithNoManagerSingleton) { + Manager::kill(); + ASSERT_EQ(Manager::getSingletonPtr(), nullptr); + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, true); + cmd.redo(); + EXPECT_FALSE(cmd.applied()); + EXPECT_FALSE(cmd.skinned()); + EXPECT_EQ(cmd.report().error, QStringLiteral("Entity no longer in scene.")); +} + +TEST(AutoRigCommandTest, RedoOnBogusEntityIsIdempotent) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + cmd.redo(); + cmd.redo(); + EXPECT_FALSE(cmd.applied()); + EXPECT_EQ(cmd.report().error, QStringLiteral("Entity no longer in scene.")); +} + +// ---- undo() before a successful redo → no-op ----------------------------- + +TEST(AutoRigCommandTest, UndoBeforeApplyIsNoOp) { + AutoRigCommand cmd(kBogusEntity, humanoidOpts(), {}, false); + // applied is false → undo() must early-return without touching anything. + EXPECT_NO_FATAL_FAILURE(cmd.undo()); + EXPECT_FALSE(cmd.applied()); + + // Same after a failed redo (still not applied). + cmd.redo(); + EXPECT_NO_FATAL_FAILURE(cmd.undo()); + EXPECT_FALSE(cmd.applied()); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cd3a57df8..6fbac4df6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -120,6 +120,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/PoseLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/PoseLibraryCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ApplyAtlas.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EmbeddedTextureCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/NormalMapGenerator.cpp From d5af3aa498d3c0e8415bda68b9a0dbbbc559df05 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 12:00:58 -0400 Subject: [PATCH 21/24] feat(#407): move auto-rig UI inline into Inspector + fix Skip - Replace the modal AutoRigDialog with an inline Rigging section in the Inspector (riggingToolsComponent). Skeleton-type picker is a primary control; up-axis stays under "Advanced options". Smart show/hide: idle shows entry points + options, marker mode swaps to the guidance label + Skip/Undo/Cancel/Rig-from-markers controls. Section cancels any active marker session when it disappears (replaces the dialog's onClosing). - Markers only offered for the humanoid template (they're humanoid-specific). - Fix Skip: marker progress is now a CURSOR into the order list. Skip advances the cursor past a slot without storing a marker (joint keeps the template fit); the old code pushed an unset placeholder that didn't count as resolved, so the cursor stuck and Skip did nothing. Place advances + stores; Undo steps back, dropping the marker if that slot was placed. New markerPlacedCount drives "Rig from markers"; markerCount = resolved slots (placed + skipped) for the N/total readout. - Remove qml/AutoRigDialog.qml + its qrc entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- qml/AutoRigDialog.qml | 395 -------------------------------------- qml/PropertiesPanel.qml | 363 +++++++++++++++++++++++++++++------ src/AutoRigController.cpp | 73 +++---- src/AutoRigController.h | 13 +- src/qml_resources.qrc | 1 - 6 files changed, 351 insertions(+), 496 deletions(-) delete mode 100644 qml/AutoRigDialog.qml diff --git a/CLAUDE.md b/CLAUDE.md index ff4d6ae0b..aed160639 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Real-ESRGAN texture upscaling** (`src/TextureUpscaler.h/cpp` + `AIAssistManager`, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. `TextureUpscaler` is the Ogre-free core (reuses `PbrMapSynth::toNCHW`/`nchwToRgb`): a **scale-aware** overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). `AIAssistManager::upscaleTexture(srcPath, scale, overwrite)` extends the per-model `Map` enum with `UpscaleX2`/`UpscaleX4`, downloads the model on first use (same HF repo), runs, caches `_upscaled_x{2,4}.png` next to the source, and emits `upscaleStarted/Completed/Error`. The Material Editor path is worker-threaded and reports state via `upscaleDownloading` (first-run model fetch) / `upscaleProgress(done,total)` (per tile) / `upscaleCompleted`/`upscaleError`; `cancelUpscale()` flips a shared atomic that the tiling loop's `ProgressFn` checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. **Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, [xinntao](https://github.com/xinntao/Real-ESRGAN))** — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via `scripts/export-realesrgan-onnx.py` (one-time, offline, NOT shipped). Surfaced via **CLI `qtmesh material --texture --upscale {2|4} [-o ]`** (`CLIPipeline::cmdMaterialUpscale`), the MCP `upscale_texture` tool, and **"Upscale 2× / 4×" buttons** in the Material Editor's Texture Properties panel. Sentry breadcrumb category `ai.assist.upscale`. ONNX intra-op threads are set to `hardware_concurrency-1` (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — `PbrMapSynth` stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded. - **LLM-assisted material from a description** (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → `MaterialEditorQML::generateMaterialFromPrompt` → `LLMManager::generateMaterial`); #406 adds the missing **CLI + MCP parity** by reusing that exact path headlessly. The shared core `CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)` resolves a GGUF model (the `--model`/`model` override, else last-used / first available via `LLMManager::scanForModels`+`availableModels`), drives `LLMManager::generateMaterial` synchronously through two `QEventLoop`s (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the `material ` header, parses the script via `MaterialManager::parseScript`, `compile()`s, honors a `pbr_workflow` tag through `RTShaderHelper::applyPbrIfTagged`, and binds the material to every submesh of the entity. The **CLI** `qtmesh material --describe "" [--model ] [-o out]` (`CLIPipeline::cmdMaterialDescribe`) imports → applies → re-exports; the **MCP** `describe_material` tool (`MCPServer::toolDescribeMaterial`, args `{prompt, mesh?, model?, output_path?}`) applies to the named/selected entity in-session and optionally re-exports when `output_path` is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — `LLMManager.cpp` always compiles, so no `#ifdef ENABLE_LOCAL_LLM` guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category `ai.assist.describe_material`. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer. - **SkinWeights** (`src/SkinWeights.h/cpp`, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights. The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is **GPL/copyleft**. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with **zero new dependencies**: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply `1/dist^falloff` weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (`maxInfluenceDistance` × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional `skipUnweightedBones` filters Mixamo helper bones. `replaceExisting=false` enables a merge mode for "fill in missing weights" workflows. Surfaced via `qtmesh skin --max-influences N --falloff F -o out`, MCP `compute_skin_weights`, and the **Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button** (`qml/SkinWeightsDialog.qml`, driven by `SkinWeightsController` singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to `hasSkinnedSelection` so it disables on static (skeleton-less) meshes. The GUI path runs through `ComputeSkinWeightsCommand` (`src/commands/`) so the auto-skin is **undoable** (Ctrl+Z): the command snapshots every submesh's `VertexBoneAssignmentList` (+ the mesh-level shared list) before the first `redo`, runs `computeAndApply`, and on `undo` restores the snapshot and calls `_compileBoneAssignments` to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category `ai.assist.skin_weights`. A future slice can plug libigl BBW in behind `-DENABLE_LIBIGL_BBW` for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip. -- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. **Mixamo-style marker placement** (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — `AutoRig::humanoidMarkerOrder()`), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is `AutoRig::fitTemplateWithMarkers` (runs `fitTemplate`, then: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→`layChain` lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→`layLeg` anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). `layChain` is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (`report.markersApplied` counts the placed ones; an empty marker set is bit-identical to `fitTemplate`). The viewport flow lives in `AutoRigController` (marker-session state machine: `beginMarkerPlacement`/`skipCurrentMarker`/`undoLastMarker`/`cancelMarkerPlacement`/`commitMarkerRig`); clicks are routed in by `TransformOperator::mousePressEvent` (checked **before** the knife/select paths when `markerMode()` is true), ray-cast to the mesh surface (`getCameraToViewportRay` → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow `PT_SPHERE` overlays. The `qml/AutoRigDialog.qml` is **non-modal** (`Qt.NonModal`) so viewport clicks reach the 3D scene, and `onClosing` cancels any active marker session. Surfaced via the "Place markers…" button + Skip/Undo/Cancel/"Rig from markers" in-session controls in the dialog (no CLI/MCP marker surface — guided placement is inherently interactive). +- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. **Mixamo-style marker placement** (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — `AutoRig::humanoidMarkerOrder()`), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is `AutoRig::fitTemplateWithMarkers` (runs `fitTemplate`, then: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→`layChain` lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→`layLeg` anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). `layChain` is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (`report.markersApplied` counts the placed ones; an empty marker set is bit-identical to `fitTemplate`). The viewport flow lives in `AutoRigController` (marker-session state machine: `beginMarkerPlacement`/`skipCurrentMarker`/`undoLastMarker`/`cancelMarkerPlacement`/`commitMarkerRig`); clicks are routed in by `TransformOperator::mousePressEvent` (checked **before** the knife/select paths when `markerMode()` is true), ray-cast to the mesh surface (`getCameraToViewportRay` → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow `PT_SPHERE` overlays. **The whole UI is inline in the Inspector's "Rigging" section** (`riggingToolsComponent` in `qml/PropertiesPanel.qml`) — there is no separate dialog (the old `AutoRigDialog.qml` was removed). It show/hides smartly: idle shows the two entry points ("Place markers…" / "Auto-Rig (template)"), a skin checkbox, and an "Advanced options" checkbox that reveals the template + up-axis pickers; while `markerMode` is active it swaps to the per-marker guidance label + Skip/Undo/Cancel/"Rig from markers" controls. Rig state + the `runAutoRig`/`runMarkerRig` helpers live on the `PropertiesPanel` root; the section's `onSectionVisibleChanged` cancels any active marker session if the section disappears (mode change / deselect / re-rig), replacing the dialog's old `onClosing` cancel. No CLI/MCP marker surface — guided placement is inherently interactive. - **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%. - **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering. - **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`. diff --git a/qml/AutoRigDialog.qml b/qml/AutoRigDialog.qml deleted file mode 100644 index b077df2d6..000000000 --- a/qml/AutoRigDialog.qml +++ /dev/null @@ -1,395 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtQuick.Window -import MaterialEditorQML 1.0 -import PropertiesPanel 1.0 - -// Issue #407: top-level Window for native auto-rigging. Same Inspector-styled -// idiom as SkinWeightsDialog / QuadRetopoDialog. Operates on the currently -// selected STATIC entity — the button disables on already-rigged or empty -// selections (AutoRigController.hasRiggableSelection). -Window { - id: dialog - title: "Auto-Rig" - width: 560 - height: 420 - minimumWidth: 480 - minimumHeight: 380 - flags: Qt.Dialog - // NON-modal: marker placement needs the user to click in the 3D viewport, - // which an application-modal dialog would block. Stays on top instead. - modality: Qt.NonModal - color: PropertiesPanelController.panelColor - - // Leaving the dialog must not strand the viewport in marker-capture mode. - onClosing: if (AutoRigController.markerMode) AutoRigController.cancelMarkerPlacement() - - property var templates: ["humanoid", "biped", "quadruped", "generic"] - property int templateIndex: 0 - property var upAxes: ["x", "y", "z"] - property int upAxisIndex: 1 // +Y default - property bool alsoSkin: true - - property string lastStatus: "" - property bool lastWasError: false - - function open() { - dialog.lastStatus = "" - dialog.lastWasError = false - dialog.show() - dialog.raise() - dialog.requestActivate() - keyCapture.forceActiveFocus() - } - - function runRig() { - if (AutoRigController.busy) return - if (!AutoRigController.hasRiggableSelection) return - const r = AutoRigController.autoRigSelected( - dialog.templates[dialog.templateIndex], - dialog.upAxes[dialog.upAxisIndex], - dialog.alsoSkin) - if (r && r.applied) { - dialog.lastStatus = - "Rigged: " + r.boneCount + " bones, " - + r.verticesSampled + " verts sampled, " - + r.jointsRecentered + " joints recentered" - + (dialog.alsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") - dialog.lastWasError = false - } else { - dialog.lastStatus = "Failed: " + (r && r.error ? r.error : "unknown error") - dialog.lastWasError = true - } - } - - function runMarkerRig() { - if (AutoRigController.busy) return - const r = AutoRigController.commitMarkerRig(dialog.alsoSkin) - if (r && r.applied) { - dialog.lastStatus = - "Rigged from markers: " + r.boneCount + " bones, " - + r.markersApplied + " markers applied" - + (dialog.alsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") - 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.runRig() - event.accepted = true - } - } - } - - // ── Inline Inspector primitives (match SkinWeightsDialog) ─────────── - - component InspectorButton: Rectangle { - id: btn - property string label: "" - property bool buttonEnabled: true - signal clicked() - 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 - 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 InspectorCheckbox: Rectangle { - id: cb - property string label: "" - property bool checked: false - signal toggled() - 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 - 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() - } - } - - // A minimal segmented picker (no ComboBox dependency, matches the - // hand-rolled Inspector style). - component InspectorSegments: Row { - id: seg - property var options: [] - property int index: 0 - signal picked(int i) - spacing: 4 - Repeater { - model: seg.options - Rectangle { - width: Math.max(60, segText.implicitWidth + 18) - height: 24 - radius: 3 - color: index === seg.index - ? PropertiesPanelController.highlightColor - : PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - Text { - id: segText - anchors.centerIn: parent - text: modelData - color: PropertiesPanelController.textColor - font.pixelSize: 11 - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: seg.picked(index) - } - } - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 16 - spacing: 12 - - InspectorLabel { - Layout.fillWidth: true - wrapMode: Text.WordWrap - opacity: 0.85 - text: "Embed a skeleton template into the selected unrigged mesh. " - + "Native heuristic (no external deps): maps a proportional joint " - + "graph into the mesh bounds and recentres joints toward the " - + "mesh's medial mass. Works best on roughly upright, manifold, " - + "T/A-pose meshes with +Y up. Already-rigged meshes are not " - + "eligible." - } - - RowLayout { - spacing: 8 - Layout.fillWidth: true - InspectorLabel { text: "Skeleton:"; Layout.preferredWidth: 80 } - InspectorSegments { - options: dialog.templates - index: dialog.templateIndex - onPicked: function(i) { dialog.templateIndex = i } - } - } - - RowLayout { - spacing: 8 - Layout.fillWidth: true - InspectorLabel { text: "Up axis:"; Layout.preferredWidth: 80 } - InspectorSegments { - options: dialog.upAxes - index: dialog.upAxisIndex - onPicked: function(i) { dialog.upAxisIndex = i } - } - InspectorLabel { - text: "(+Y is the in-app default after import)" - opacity: 0.7 - Layout.fillWidth: true - wrapMode: Text.WordWrap - } - } - - RowLayout { - spacing: 8 - Layout.fillWidth: true - InspectorLabel { text: ""; Layout.preferredWidth: 80 } - InspectorCheckbox { - Layout.fillWidth: true - label: "Also compute skin weights (one-click rig + skin)" - checked: dialog.alsoSkin - onToggled: dialog.alsoSkin = !dialog.alsoSkin - } - } - - // ── Mixamo-style marker placement ─────────────────────────────── - Rectangle { - Layout.fillWidth: true - Layout.topMargin: 6 - height: markerCol.implicitHeight + 16 - color: PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - radius: 3 - - ColumnLayout { - id: markerCol - anchors.fill: parent - anchors.margins: 8 - spacing: 6 - - InspectorLabel { - Layout.fillWidth: true - wrapMode: Text.WordWrap - opacity: 0.85 - text: "Better fit: place markers on the mesh (Mixamo-style). " - + "Click each point in the viewport; the skeleton fits the " - + "marked limbs instead of fixed proportions. Unmarked → template." - } - - // Active-mode guidance: which marker to click + progress. - InspectorLabel { - Layout.fillWidth: true - visible: AutoRigController.markerMode - wrapMode: Text.WordWrap - color: PropertiesPanelController.highlightColor - text: AutoRigController.currentMarkerLabel.length > 0 - ? ("Click: " + AutoRigController.currentMarkerLabel - + " (" + AutoRigController.markerCount + "/" - + AutoRigController.markerTotal + " placed)") - : ("All markers placed (" + AutoRigController.markerCount - + "/" + AutoRigController.markerTotal + ") — click 'Rig from markers'") - } - - RowLayout { - Layout.fillWidth: true - spacing: 6 - // Enter marker mode. - InspectorButton { - visible: !AutoRigController.markerMode - label: "Place markers…" - Layout.preferredWidth: 130 - buttonEnabled: !AutoRigController.busy - && AutoRigController.hasRiggableSelection - onClicked: AutoRigController.beginMarkerPlacement(dialog.upAxes[dialog.upAxisIndex]) - } - // In-session controls. - InspectorButton { - visible: AutoRigController.markerMode - label: "Skip" - Layout.preferredWidth: 64 - buttonEnabled: AutoRigController.currentMarkerLabel.length > 0 - onClicked: AutoRigController.skipCurrentMarker() - } - InspectorButton { - visible: AutoRigController.markerMode - label: "Undo" - Layout.preferredWidth: 64 - buttonEnabled: AutoRigController.markerCount > 0 - onClicked: AutoRigController.undoLastMarker() - } - InspectorButton { - visible: AutoRigController.markerMode - label: "Cancel" - Layout.preferredWidth: 72 - onClicked: AutoRigController.cancelMarkerPlacement() - } - Item { Layout.fillWidth: true } - InspectorButton { - visible: AutoRigController.markerMode - label: AutoRigController.busy ? "Rigging…" : "Rig from markers" - Layout.preferredWidth: 150 - // Allow committing once at least one marker is placed - // (the rest fall back to the template). - buttonEnabled: !AutoRigController.busy - && AutoRigController.markerCount > 0 - onClicked: dialog.runMarkerRig() - } - } - } - } - - Item { Layout.fillHeight: true } - - InspectorLabel { - Layout.fillWidth: true - visible: dialog.lastStatus.length > 0 - text: dialog.lastStatus - wrapMode: Text.WordWrap - color: dialog.lastWasError ? "#cc4444" : "#3a8c3a" - } - - RowLayout { - Layout.fillWidth: true - Item { Layout.fillWidth: true } - InspectorButton { - label: "Close" - Layout.preferredWidth: 90 - onClicked: dialog.close() - } - InspectorButton { - label: AutoRigController.busy ? "Rigging…" : "Auto-Rig" - Layout.preferredWidth: 160 - buttonEnabled: !AutoRigController.busy - && AutoRigController.hasRiggableSelection - onClicked: dialog.runRig() - } - } - } - - Connections { - target: AutoRigController - function onError(msg) { - dialog.lastStatus = "Failed: " + msg - dialog.lastWasError = true - } - } -} diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index a5abb4c5d..f4e06acf3 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -20,6 +20,154 @@ Rectangle { property bool showAllModeTools: false property var bottomToolHost: null + // ---- Auto-rig (#407) inline state, lives in the Inspector Rigging section + // (replaces the old modal AutoRigDialog) ---- + property var rigTemplates: ["humanoid", "biped", "quadruped", "generic"] + property int rigTemplateIndex: 0 + property var rigUpAxes: ["x", "y", "z"] + property int rigUpAxisIndex: 1 // +Y default + property bool rigAlsoSkin: true + property bool rigShowAdvanced: false // template / up-axis pickers + property string rigStatus: "" + property bool rigStatusError: false + + function runAutoRig() { + if (AutoRigController.busy || !AutoRigController.hasRiggableSelection) return + const r = AutoRigController.autoRigSelected( + root.rigTemplates[root.rigTemplateIndex], + root.rigUpAxes[root.rigUpAxisIndex], + root.rigAlsoSkin) + if (r && r.applied) { + root.rigStatus = "Rigged: " + r.boneCount + " bones, " + + r.verticesSampled + " verts, " + + r.jointsRecentered + " recentered" + + (root.rigAlsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + root.rigStatusError = false + } else { + root.rigStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + root.rigStatusError = true + } + } + + function runMarkerRig() { + if (AutoRigController.busy) return + const r = AutoRigController.commitMarkerRig(root.rigAlsoSkin) + if (r && r.applied) { + root.rigStatus = "Rigged from markers: " + r.boneCount + " bones, " + + r.markersApplied + " markers" + + (root.rigAlsoSkin ? (r.skinned ? " (+ skinned)" : " (skin failed)") : "") + root.rigStatusError = false + } else { + root.rigStatus = "Failed: " + (r && r.error ? r.error : "unknown error") + root.rigStatusError = true + } + } + + Connections { + target: AutoRigController + function onError(msg) { + root.rigStatus = "Failed: " + msg + root.rigStatusError = true + } + } + + // ---- Small inline Inspector primitives reused by the Rigging section ---- + component RigButton: Rectangle { + id: rb + property string label: "" + property bool buttonEnabled: true + signal clicked() + implicitWidth: rbText.implicitWidth + 18 + height: 24 + radius: 3 + opacity: rb.buttonEnabled ? 1.0 : 0.45 + color: rbMa.containsMouse && rb.buttonEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: rbText + anchors.centerIn: parent + text: rb.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: rbMa + anchors.fill: parent + hoverEnabled: true + enabled: rb.buttonEnabled + cursorShape: rb.buttonEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: rb.clicked() + } + } + + component RigCheckbox: Row { + id: rcb + property string label: "" + property bool checked: false + signal toggled() + spacing: 6 + Rectangle { + width: 14; height: 14; radius: 2 + anchors.verticalCenter: parent.verticalCenter + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + anchors.centerIn: parent + text: rcb.checked ? "✓" : "" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: rcb.label + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: rcb.toggled() + } + } + + component RigSegments: Row { + id: rseg + property var options: [] + property int index: 0 + signal picked(int i) + spacing: 4 + Repeater { + model: rseg.options + Rectangle { + width: Math.max(56, rsegText.implicitWidth + 16) + height: 22 + radius: 3 + color: index === rseg.index + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: rsegText + anchors.centerIn: parent + text: modelData + color: PropertiesPanelController.textColor + font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: rseg.picked(index) + } + } + } + } + function revealBottomTool(toolId) { if (bottomToolHost && bottomToolHost.revealBottomTool) bottomToolHost.revealBottomTool(toolId) @@ -339,6 +487,7 @@ Rectangle { // static mesh); already-rigged meshes show the Skinning section // instead. CollapsibleSection { + id: riggingSection title: "Rigging" sectionVisible: root.currentTab === root.modeToolsTab && root.modeToolMatches(EditorModeController.AnimationMode) @@ -346,6 +495,13 @@ Rectangle { expanded: false Component.onCompleted: content = riggingToolsComponent + + // Don't strand the viewport in marker-capture mode if the + // section disappears (mode change, deselect, re-rig) — the + // inline UI replaced the dialog's onClosing cancel. + onSectionVisibleChanged: if (!sectionVisible + && AutoRigController.markerMode) + AutoRigController.cancelMarkerPlacement() } // ---- Skeleton (Animation mode) ---- @@ -1304,16 +1460,24 @@ Rectangle { } // ---- Rigging Tools Content (Animation mode) ---- - // Issue #407: native auto-rig. The "Auto-Rig…" button opens the dialog - // (template picker + skin checkbox); it disables on non-static meshes - // (AutoRigController.hasRiggableSelection). + // Issue #407: native auto-rig, inline in the Inspector (no modal dialog). + // Smart show/hide: + // * marker mode active → only the guidance + in-session controls show, + // * idle → the two entry points (markers / template), + // skin checkbox, and a collapsible "Advanced" + // block (template + up-axis pickers). + // All gated on AutoRigController.hasRiggableSelection (a static mesh). Component { id: riggingToolsComponent Column { + id: rigCol width: parent ? parent.width : 200 padding: 8 - spacing: 6 + spacing: 8 + + readonly property bool canRig: AutoRigController.hasRiggableSelection + readonly property bool marking: AutoRigController.markerMode Text { width: parent.width - 16 @@ -1321,53 +1485,144 @@ Rectangle { opacity: 0.8 color: PropertiesPanelController.textColor font.pixelSize: 10 - text: "Embed a skeleton template (humanoid / biped / quadruped / " - + "generic) into the selected unrigged mesh, optionally skinning " - + "it in one click. Best on upright, manifold, T/A-pose meshes." + text: rigCol.marking + ? "Click each highlighted point on the mesh in the viewport." + : (rigCol.canRig + ? "Embed a skeleton into this unrigged mesh. Use markers for a " + + "better fit (Mixamo-style), or a plain template. Optionally " + + "skin in one click." + : "Select a static (unrigged) mesh to enable rigging.") } - Rectangle { - id: rigBtn - width: Math.min(parent.width - 16, rigLabel.implicitWidth + 16) - height: 26 - radius: 3 - opacity: AutoRigController.hasRiggableSelection ? 1.0 : 0.45 - color: rigMa.containsMouse && AutoRigController.hasRiggableSelection - ? PropertiesPanelController.highlightColor - : PropertiesPanelController.headerColor - activeFocusOnTab: AutoRigController.hasRiggableSelection - Accessible.role: Accessible.Button - Accessible.name: "Auto-Rig" - Keys.onSpacePressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() - Keys.onReturnPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() - Keys.onEnterPressed: if (AutoRigController.hasRiggableSelection) root.openAutoRigDialog() - border.color: rigBtn.activeFocus - ? PropertiesPanelController.highlightColor - : PropertiesPanelController.borderColor - border.width: rigBtn.activeFocus ? 2 : 1 + // ── Marker mode: guidance + in-session controls only ────────── + Column { + width: parent.width - 16 + spacing: 6 + visible: rigCol.marking Text { - id: rigLabel - anchors.centerIn: parent - text: "Auto-Rig…" - color: PropertiesPanelController.textColor + width: parent.width + wrapMode: Text.Wrap font.pixelSize: 11 + color: PropertiesPanelController.highlightColor + text: AutoRigController.currentMarkerLabel.length > 0 + ? ("Place: " + AutoRigController.currentMarkerLabel + + " (" + AutoRigController.markerCount + "/" + + AutoRigController.markerTotal + ")") + : ("All " + AutoRigController.markerCount + "/" + + AutoRigController.markerTotal + + " placed — click 'Rig from markers'") } - MouseArea { - id: rigMa - anchors.fill: parent - hoverEnabled: true - enabled: AutoRigController.hasRiggableSelection - cursorShape: AutoRigController.hasRiggableSelection - ? Qt.PointingHandCursor : Qt.ForbiddenCursor - onClicked: root.openAutoRigDialog() - ToolTip.visible: containsMouse - ToolTip.delay: 500 - ToolTip.text: AutoRigController.hasRiggableSelection - ? "Generate a skeleton for this static mesh by embedding a template." - : "Select a static (unrigged) mesh first." + + Flow { + width: parent.width + spacing: 6 + RigButton { + label: "Skip" + buttonEnabled: AutoRigController.currentMarkerLabel.length > 0 + onClicked: AutoRigController.skipCurrentMarker() + } + RigButton { + label: "Undo" + buttonEnabled: AutoRigController.markerCount > 0 + onClicked: AutoRigController.undoLastMarker() + } + RigButton { + label: "Cancel" + onClicked: AutoRigController.cancelMarkerPlacement() + } + RigButton { + label: AutoRigController.busy ? "Rigging…" : "Rig from markers" + buttonEnabled: !AutoRigController.busy + && AutoRigController.markerPlacedCount > 0 + onClicked: root.runMarkerRig() + } + } + } + + // ── Idle: skeleton type + entry points + options ────────────── + Column { + width: parent.width - 16 + spacing: 8 + visible: !rigCol.marking + + // Skeleton type — a primary choice, always visible. + Text { + text: "Skeleton type" + color: PropertiesPanelController.textColor + opacity: 0.8 + font.pixelSize: 10 + } + Flow { + width: parent.width + spacing: 4 + RigSegments { + options: root.rigTemplates + index: root.rigTemplateIndex + onPicked: function(i) { root.rigTemplateIndex = i } + } + } + + Flow { + width: parent.width + spacing: 6 + RigButton { + // Markers are a humanoid concept (chin/shoulders/wrists/ + // hips/knees) — only offered for the humanoid template. + label: "Place markers…" + buttonEnabled: rigCol.canRig && !AutoRigController.busy + && root.rigTemplates[root.rigTemplateIndex] === "humanoid" + onClicked: AutoRigController.beginMarkerPlacement( + root.rigUpAxes[root.rigUpAxisIndex]) + } + RigButton { + label: AutoRigController.busy ? "Rigging…" : "Auto-Rig (template)" + buttonEnabled: rigCol.canRig && !AutoRigController.busy + onClicked: root.runAutoRig() + } + } + + RigCheckbox { + label: "Also compute skin weights" + checked: root.rigAlsoSkin + onToggled: root.rigAlsoSkin = !root.rigAlsoSkin + } + + // Advanced options toggle (just the up-axis picker for now). + RigCheckbox { + label: "Advanced options" + checked: root.rigShowAdvanced + onToggled: root.rigShowAdvanced = !root.rigShowAdvanced + } + + Column { + width: parent.width + spacing: 6 + visible: root.rigShowAdvanced + + Text { + text: "Up axis (+Y is the in-app default)" + color: PropertiesPanelController.textColor + opacity: 0.8 + font.pixelSize: 10 + } + RigSegments { + options: root.rigUpAxes + index: root.rigUpAxisIndex + onPicked: function(i) { root.rigUpAxisIndex = i } + } } } + + // ── Status line (both modes) ────────────────────────────────── + Text { + width: parent.width - 16 + visible: root.rigStatus.length > 0 + wrapMode: Text.Wrap + font.pixelSize: 10 + text: root.rigStatus + color: root.rigStatusError ? "#cc4444" : "#3a8c3a" + } } } @@ -4298,25 +4553,9 @@ Rectangle { } } - // Issue #407: native auto-rig dialog. Same lazy-load idiom. - Loader { - id: autoRigLoader - active: false - anchors.centerIn: parent - source: "qrc:/MaterialEditorQML/AutoRigDialog.qml" - onLoaded: if (item && item.open) item.open() - } - function openAutoRigDialog() { - if (!autoRigLoader.active) { - autoRigLoader.active = true - } else if (autoRigLoader.item) { - autoRigLoader.item.open() - } else if (autoRigLoader.status === Loader.Error) { - // Failed load left active=true / item=null — reset so a retry works. - autoRigLoader.active = false - autoRigLoader.active = true - } - } + // Issue #407: native auto-rig now lives inline in the Inspector Rigging + // section (riggingToolsComponent) — no modal dialog. The old AutoRigDialog + // Loader / openAutoRigDialog() were removed. Loader { id: isometricSpritesLoader diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp index 46bbc6126..b31117e9f 100644 --- a/src/AutoRigController.cpp +++ b/src/AutoRigController.cpp @@ -255,9 +255,9 @@ Ogre::Entity* AutoRigController::selectedRiggableEntity() const int AutoRigController::markerCount() const { - int n = 0; - for (const auto& m : m_markers) if (m.set) ++n; - return n; + // Slots resolved so far (placed OR skipped) = the cursor position. Drives + // the "N/total" progress readout in the UI. + return m_markerCursor; } int AutoRigController::markerTotal() const @@ -265,15 +265,18 @@ int AutoRigController::markerTotal() const return static_cast(m_markerOrder.size()); } +int AutoRigController::markerPlacedCount() const +{ + // Only the actually-placed (set) markers — what "Rig from markers" needs. + return static_cast(m_markers.size()); +} + QString AutoRigController::currentMarkerLabel() const { - // The next unset marker in order. - for (auto id : m_markerOrder) { - bool placed = false; - for (const auto& m : m_markers) if (m.id == id && m.set) { placed = true; break; } - if (!placed) return AutoRig::markerLabel(id); - } - return QString(); // all placed + // The slot at the cursor (empty once every slot is resolved). + if (m_markerCursor < 0 || m_markerCursor >= static_cast(m_markerOrder.size())) + return QString(); + return AutoRig::markerLabel(m_markerOrder[m_markerCursor]); } bool AutoRigController::beginMarkerPlacement(const QString& upAxis) @@ -286,6 +289,7 @@ bool AutoRigController::beginMarkerPlacement(const QString& upAxis) m_markerEntityName = e->getName(); m_markerOrder = AutoRig::humanoidMarkerOrder(); m_markers.clear(); + m_markerCursor = 0; clearMarkerOverlays(); m_markerMode = true; @@ -303,6 +307,7 @@ void AutoRigController::cancelMarkerPlacement() m_markerMode = false; m_markers.clear(); m_markerOrder.clear(); + m_markerCursor = 0; clearMarkerOverlays(); emit markerModeChanged(); emit markerCountChanged(); @@ -311,24 +316,24 @@ void AutoRigController::cancelMarkerPlacement() void AutoRigController::skipCurrentMarker() { if (!m_markerMode) return; - // Record an explicit "skipped" placeholder (set=false but consumed) by - // advancing past the current marker: insert an unset marker so current - // MarkerLabel moves on. - const QString cur = currentMarkerLabel(); - if (cur.isEmpty()) return; - for (auto id : m_markerOrder) { - if (AutoRig::markerLabel(id) != cur) continue; - AutoRig::Marker m; m.id = id; m.set = false; - m_markers.push_back(m); // unset → keeps template, but consumes the slot - break; - } + if (m_markerCursor >= static_cast(m_markerOrder.size())) return; + // Just advance the cursor past this slot — no marker is stored, so the + // joint keeps its template fit. (No m_markers entry; the cursor is what + // makes currentMarkerLabel move on.) + ++m_markerCursor; emit markerCountChanged(); } void AutoRigController::undoLastMarker() { - if (!m_markerMode || m_markers.empty()) return; - m_markers.pop_back(); + if (!m_markerMode || m_markerCursor <= 0) return; + // Step back one slot. If that slot was PLACED (its id is in m_markers), + // drop the marker too; if it was skipped, there's nothing to remove. + --m_markerCursor; + const AutoRig::MarkerId id = m_markerOrder[m_markerCursor]; + for (auto it = m_markers.begin(); it != m_markers.end(); ++it) { + if (it->id == id) { m_markers.erase(it); break; } + } refreshMarkerOverlays(); emit markerCountChanged(); } @@ -336,8 +341,10 @@ void AutoRigController::undoLastMarker() bool AutoRigController::handleMarkerClick(OgreWidget* widget, const QPoint& screenPos) { if (!m_markerMode || !widget) return false; - const QString cur = currentMarkerLabel(); - if (cur.isEmpty()) return true; // all placed; consume click but do nothing + if (m_markerCursor < 0 || m_markerCursor >= static_cast(m_markerOrder.size())) + return true; // all slots resolved; consume click but do nothing + const AutoRig::MarkerId curId = m_markerOrder[m_markerCursor]; + const QString cur = AutoRig::markerLabel(curId); Ogre::Entity* e = selectedRiggableEntity(); if (!e || e->getName() != m_markerEntityName) { @@ -374,15 +381,13 @@ bool AutoRigController::handleMarkerClick(OgreWidget* widget, const QPoint& scre const Ogre::Vector3 local = node ? node->_getFullTransform().inverse() * hit : hit; - // Find which MarkerId is current and record it. - for (auto id : m_markerOrder) { - if (AutoRig::markerLabel(id) != cur) continue; - AutoRig::Marker m; - m.id = id; m.set = true; - m.pos = { local.x, local.y, local.z }; - m_markers.push_back(m); - break; - } + // Record the marker for the current slot and advance the cursor. + AutoRig::Marker m; + m.id = curId; m.set = true; + m.pos = { local.x, local.y, local.z }; + m_markers.push_back(m); + ++m_markerCursor; + refreshMarkerOverlays(); emit markerPlaced(cur); emit markerCountChanged(); diff --git a/src/AutoRigController.h b/src/AutoRigController.h index b7526c8ac..e4372cfe0 100644 --- a/src/AutoRigController.h +++ b/src/AutoRigController.h @@ -36,6 +36,7 @@ class AutoRigController : public QObject Q_PROPERTY(bool markerMode READ markerMode NOTIFY markerModeChanged) Q_PROPERTY(int markerCount READ markerCount NOTIFY markerCountChanged) Q_PROPERTY(int markerTotal READ markerTotal NOTIFY markerModeChanged) + Q_PROPERTY(int markerPlacedCount READ markerPlacedCount NOTIFY markerCountChanged) Q_PROPERTY(QString currentMarkerLabel READ currentMarkerLabel NOTIFY markerCountChanged) public: @@ -57,8 +58,9 @@ class AutoRigController : public QObject // ---- Marker placement (Mixamo-style) ------------------------------- bool markerMode() const { return m_markerMode; } - int markerCount() const; // markers placed so far + int markerCount() const; // slots resolved (placed+skipped) int markerTotal() const; // total expected (10 for humanoid) + int markerPlacedCount() const; // only the placed (set) markers QString currentMarkerLabel() const; // label of the next marker to place /// Enter marker mode for the selected static mesh. Subsequent viewport @@ -100,11 +102,16 @@ class AutoRigController : public QObject static AutoRigController* m_pSingleton; bool m_busy = false; - // Marker session. + // Marker session. Progress is a CURSOR into m_markerOrder: slots before the + // cursor are resolved (either placed in m_markers, or skipped — absent from + // m_markers). The cursor — not the contents of m_markers — drives which + // marker is "current", so Skip advances past a slot without placing it and + // the cursor never sticks. m_markers holds only the placed (set) markers. bool m_markerMode = false; int m_upAxis = 1; // resolved at begin + int m_markerCursor = 0; // next slot to resolve std::vector m_markerOrder; // the 10, in click order - std::vector m_markers; // accumulated (set flag) + std::vector m_markers; // PLACED markers (set) std::vector m_markerNodes; // viewport sphere overlays std::string m_markerEntityName; // entity being marked }; diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index a4b713ec6..e03fc373e 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -12,7 +12,6 @@ ../qml/UvUnwrapDialog.qml ../qml/QuadRetopoDialog.qml ../qml/SkinWeightsDialog.qml - ../qml/AutoRigDialog.qml ../qml/IsometricSpritesDialog.qml ../qml/qmldir ../qml/ThemedButton.qml From be48eb7a48529d55cca7b43341efd1592c7bd0be Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 14:42:53 -0400 Subject: [PATCH 22/24] =?UTF-8?q?fix(#407):=20undoable=20rig=20crash=20?= =?UTF-8?q?=E2=80=94=20strip=20blend=20vertex=20elements=20on=20unrig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rigging with skin runs Ogre's _compileBoneAssignments, which adds BLEND_INDICES/BLEND_WEIGHTS vertex elements. On undo, clearing the bone-assignment list is NOT enough — Ogre only removes those elements inside compileBoneAssignments, which it skips when the list is empty, so the declaration kept advertising blend elements while the entity had no skeleton → null SkeletonInstance deref on the next _initialise/render. AutoRig::unrigEntity now explicitly strips the blend elements (unbind the buffer + removeElement) on shared + per-submesh vertex data, leaving a genuinely static mesh. AutoRigCommand also calls AutoRigController::notifyRiggingChanged on redo/undo — on undo BEFORE detaching, so any active skeleton-debug overlay tears down while the skeleton still exists (else it dangles), and the Inspector re-evaluates the Rigging/Skeleton sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AutoRig.cpp | 29 +++++++++++++++++++++++++++-- src/AutoRigController.cpp | 12 ++++++++++++ src/AutoRigController.h | 6 ++++++ src/commands/AutoRigCommand.cpp | 11 +++++++++-- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp index 49ad7317b..b6e49dbca 100644 --- a/src/AutoRig.cpp +++ b/src/AutoRig.cpp @@ -583,12 +583,37 @@ bool AutoRig::unrigEntity(Ogre::Entity* entity) // Remember the skeleton resource name so we can free it after detaching. const std::string skelName = mesh->getSkeletonName(); + // Strip the BLEND_INDICES/BLEND_WEIGHTS vertex elements that the skin step + // (`_compileBoneAssignments`) added. CRUCIAL for undo when the rig was + // committed with skinning: clearing the bone-assignment LIST is not enough + // — Ogre's `_compileBoneAssignments` only *removes* the blend elements + // inside `compileBoneAssignments`, which it skips entirely when the list is + // empty (maxBones == 0). So a plain clear leaves the vertex declaration + // advertising blend elements while the entity has no skeleton, and the next + // `_initialise`/render dereferences a null SkeletonInstance → crash. We + // mirror Ogre's own removal block (unset the buffer, drop both elements). + auto stripBlend = [](Ogre::VertexData* vd) { + if (!vd) return; + Ogre::VertexDeclaration* decl = vd->vertexDeclaration; + Ogre::VertexBufferBinding* bind = vd->vertexBufferBinding; + const Ogre::VertexElement* e = + decl->findElementBySemantic(Ogre::VES_BLEND_INDICES); + if (!e) return; + bind->unsetBinding(e->getSource()); + decl->removeElement(Ogre::VES_BLEND_INDICES); + decl->removeElement(Ogre::VES_BLEND_WEIGHTS); + }; + // Drop every bone assignment (shared + per-submesh) so the mesh carries no - // stale weights once it's static again. + // stale weights once it's static again, then strip the blend elements. mesh->clearBoneAssignments(); + stripBlend(mesh->sharedVertexData); for (unsigned short si = 0; si < mesh->getNumSubMeshes(); ++si) { - if (Ogre::SubMesh* sub = mesh->getSubMesh(si)) + if (Ogre::SubMesh* sub = mesh->getSubMesh(si)) { sub->clearBoneAssignments(); + if (!sub->useSharedVertices) + stripBlend(sub->vertexData); + } } // Detach the skeleton and force the entity back to its static form (mirror diff --git a/src/AutoRigController.cpp b/src/AutoRigController.cpp index b31117e9f..6ceec6ced 100644 --- a/src/AutoRigController.cpp +++ b/src/AutoRigController.cpp @@ -7,6 +7,7 @@ #include "OgreWidget.h" #include "SpaceCamera.h" #include "UndoManager.h" +#include "PropertiesPanelController.h" #include "commands/AutoRigCommand.h" #include @@ -313,6 +314,17 @@ void AutoRigController::cancelMarkerPlacement() emit markerCountChanged(); } +void AutoRigController::notifyRiggingChanged(const std::string& entityName) +{ + // Drop any skeleton-debug overlay on this entity — once the skeleton state + // flips (rig ↔ unrig on undo/redo) a previously-shown overlay references a + // skeleton instance that's being recreated/destroyed, which would dangle. + if (auto* ppc = PropertiesPanelController::instance()) + ppc->toggleSkeletonDebug(QString::fromStdString(entityName), false); + // Re-evaluate the Inspector Rigging / Skeleton section visibility. + emit selectionChanged(); +} + void AutoRigController::skipCurrentMarker() { if (!m_markerMode) return; diff --git a/src/AutoRigController.h b/src/AutoRigController.h index e4372cfe0..c32f6e627 100644 --- a/src/AutoRigController.h +++ b/src/AutoRigController.h @@ -82,6 +82,12 @@ class AutoRigController : public QObject /// Returns true if the click was consumed (so the operator skips select). bool handleMarkerClick(OgreWidget* widget, const QPoint& screenPos); + /// Called by AutoRigCommand after a rig/unrig (incl. undo/redo). Drops any + /// active skeleton-debug overlay on the entity (it would dangle once the + /// skeleton state flips) and emits selectionChanged so the Inspector + /// re-evaluates the Rigging / Skeleton sections. + void notifyRiggingChanged(const std::string& entityName); + signals: void selectionChanged(); void busyChanged(); diff --git a/src/commands/AutoRigCommand.cpp b/src/commands/AutoRigCommand.cpp index acbdf7cf1..686cf030b 100644 --- a/src/commands/AutoRigCommand.cpp +++ b/src/commands/AutoRigCommand.cpp @@ -1,6 +1,7 @@ #include "commands/AutoRigCommand.h" #include "Manager.h" #include "SkinWeights.h" +#include "AutoRigController.h" #include #include @@ -51,11 +52,17 @@ void AutoRigCommand::redo() mReport.error = QStringLiteral("rigged, but skinning failed: %1") .arg(sw.error); } + if (mReport.applied) + AutoRigController::instance()->notifyRiggingChanged(mEntityName); } void AutoRigCommand::undo() { if (!mReport.applied) return; // nothing was attached - if (Ogre::Entity* entity = resolveEntity()) - AutoRig::unrigEntity(entity); + Ogre::Entity* entity = resolveEntity(); + if (!entity) return; + // Drop any skeleton-debug overlay BEFORE detaching the skeleton (it would + // otherwise dangle), then revert to a static mesh. + AutoRigController::instance()->notifyRiggingChanged(mEntityName); + AutoRig::unrigEntity(entity); } From a7b508449f3a64b4bb9f86f90589cec515322a69 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 14:56:55 -0400 Subject: [PATCH 23/24] feat(#407): coherent marker inference + mesh-bounds clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-marker patching with a resolve-then-lay model: fitTemplate gives a proportional baseline + template segment vectors, then every key joint (Head, L/R Shoulder, L/R Hand, Hips, L/R UpLeg, L/R Knee) is resolved marked → inferred-from-marked-neighbours → template, and the dependent chains (spine/arms/legs) are laid from those anchors. A partial marker set now yields an anatomically-sane skeleton instead of stranding unmarked joints at the template (no more shoulder-above-head). Inference: Hips ← up-leg midpoint + template rise; Head ← template offset above Hips; UpLeg ← mirror the other / pelvis + socket offset; Shoulder ← along the live Hips→Head line at the template height fraction + lateral offset (chin+hips imply sane shoulders), else mirror; Hand ← shoulder + template arm vector (marked shoulder + skipped wrist still lays a full arm); mirroring reflects across the auto-detected sagittal plane. Mesh-bounds clamp: inferred legs no longer punch through the model. Knee skipped → foot drops to the mesh floor (AABB mn[up]) below the up-leg, knee halfway between; marked knee → foot extrapolated but floor-clamped; knee always kept between up-leg and foot in the up axis. Empty marker set still early-returns fitTemplate unchanged. Tests added for each inference rule + the floor clamp. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src/AutoRig.cpp | 292 ++++++++++++++++++++++++++++--------------- src/AutoRig_test.cpp | 147 ++++++++++++++++++++++ 3 files changed, 338 insertions(+), 103 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aed160639..0d1bd4679 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Real-ESRGAN texture upscaling** (`src/TextureUpscaler.h/cpp` + `AIAssistManager`, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. `TextureUpscaler` is the Ogre-free core (reuses `PbrMapSynth::toNCHW`/`nchwToRgb`): a **scale-aware** overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). `AIAssistManager::upscaleTexture(srcPath, scale, overwrite)` extends the per-model `Map` enum with `UpscaleX2`/`UpscaleX4`, downloads the model on first use (same HF repo), runs, caches `_upscaled_x{2,4}.png` next to the source, and emits `upscaleStarted/Completed/Error`. The Material Editor path is worker-threaded and reports state via `upscaleDownloading` (first-run model fetch) / `upscaleProgress(done,total)` (per tile) / `upscaleCompleted`/`upscaleError`; `cancelUpscale()` flips a shared atomic that the tiling loop's `ProgressFn` checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. **Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, [xinntao](https://github.com/xinntao/Real-ESRGAN))** — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via `scripts/export-realesrgan-onnx.py` (one-time, offline, NOT shipped). Surfaced via **CLI `qtmesh material --texture --upscale {2|4} [-o ]`** (`CLIPipeline::cmdMaterialUpscale`), the MCP `upscale_texture` tool, and **"Upscale 2× / 4×" buttons** in the Material Editor's Texture Properties panel. Sentry breadcrumb category `ai.assist.upscale`. ONNX intra-op threads are set to `hardware_concurrency-1` (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — `PbrMapSynth` stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded. - **LLM-assisted material from a description** (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → `MaterialEditorQML::generateMaterialFromPrompt` → `LLMManager::generateMaterial`); #406 adds the missing **CLI + MCP parity** by reusing that exact path headlessly. The shared core `CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)` resolves a GGUF model (the `--model`/`model` override, else last-used / first available via `LLMManager::scanForModels`+`availableModels`), drives `LLMManager::generateMaterial` synchronously through two `QEventLoop`s (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the `material ` header, parses the script via `MaterialManager::parseScript`, `compile()`s, honors a `pbr_workflow` tag through `RTShaderHelper::applyPbrIfTagged`, and binds the material to every submesh of the entity. The **CLI** `qtmesh material --describe "" [--model ] [-o out]` (`CLIPipeline::cmdMaterialDescribe`) imports → applies → re-exports; the **MCP** `describe_material` tool (`MCPServer::toolDescribeMaterial`, args `{prompt, mesh?, model?, output_path?}`) applies to the named/selected entity in-session and optionally re-exports when `output_path` is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — `LLMManager.cpp` always compiles, so no `#ifdef ENABLE_LOCAL_LLM` guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category `ai.assist.describe_material`. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer. - **SkinWeights** (`src/SkinWeights.h/cpp`, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights. The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is **GPL/copyleft**. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with **zero new dependencies**: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply `1/dist^falloff` weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (`maxInfluenceDistance` × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional `skipUnweightedBones` filters Mixamo helper bones. `replaceExisting=false` enables a merge mode for "fill in missing weights" workflows. Surfaced via `qtmesh skin --max-influences N --falloff F -o out`, MCP `compute_skin_weights`, and the **Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button** (`qml/SkinWeightsDialog.qml`, driven by `SkinWeightsController` singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to `hasSkinnedSelection` so it disables on static (skeleton-less) meshes. The GUI path runs through `ComputeSkinWeightsCommand` (`src/commands/`) so the auto-skin is **undoable** (Ctrl+Z): the command snapshots every submesh's `VertexBoneAssignmentList` (+ the mesh-level shared list) before the first `redo`, runs `computeAndApply`, and on `undo` restores the snapshot and calls `_compileBoneAssignments` to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category `ai.assist.skin_weights`. A future slice can plug libigl BBW in behind `-DENABLE_LIBIGL_BBW` for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip. -- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. **Mixamo-style marker placement** (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — `AutoRig::humanoidMarkerOrder()`), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is `AutoRig::fitTemplateWithMarkers` (runs `fitTemplate`, then: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→`layChain` lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→`layLeg` anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). `layChain` is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (`report.markersApplied` counts the placed ones; an empty marker set is bit-identical to `fitTemplate`). The viewport flow lives in `AutoRigController` (marker-session state machine: `beginMarkerPlacement`/`skipCurrentMarker`/`undoLastMarker`/`cancelMarkerPlacement`/`commitMarkerRig`); clicks are routed in by `TransformOperator::mousePressEvent` (checked **before** the knife/select paths when `markerMode()` is true), ray-cast to the mesh surface (`getCameraToViewportRay` → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow `PT_SPHERE` overlays. **The whole UI is inline in the Inspector's "Rigging" section** (`riggingToolsComponent` in `qml/PropertiesPanel.qml`) — there is no separate dialog (the old `AutoRigDialog.qml` was removed). It show/hides smartly: idle shows the two entry points ("Place markers…" / "Auto-Rig (template)"), a skin checkbox, and an "Advanced options" checkbox that reveals the template + up-axis pickers; while `markerMode` is active it swaps to the per-marker guidance label + Skip/Undo/Cancel/"Rig from markers" controls. Rig state + the `runAutoRig`/`runMarkerRig` helpers live on the `PropertiesPanel` root; the section's `onSectionVisibleChanged` cancels any active marker session if the section disappears (mode change / deselect / re-rig), replacing the dialog's old `onClosing` cancel. No CLI/MCP marker surface — guided placement is inherently interactive. +- **AutoRig** (`src/AutoRig.h/cpp`, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping **Pinocchio** (Baran & Popović, SIGGRAPH 2007), but Pinocchio's **core library is LGPL-2.1-or-later** (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's *algorithm* (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with **zero new dependencies**. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. `rigEntity()` builds an `Ogre::Skeleton` (parent-relative bone positions, `setBindingPose`), binds via `mesh->_notifySkeleton` **+ `entity->_initialise(true)`** — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on `entity->hasSkeleton()`) silently drop the new rig. Pure-data core (`templateJoints` / `fitTemplate`) is unit-tested without GL. Surfaced via `qtmesh rig [--skeleton T] [--skin] [--up-axis x|y|z] -o out` (`CLIPipeline::cmdRig`, optionally chains `SkinWeights::computeAndApply` for one-click rig+skin), MCP `auto_rig` `{template, skin?, up_axis?, output_path?}` (`MCPServer::toolAutoRig`), and the **Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button** (`qml/AutoRigDialog.qml`, driven by `AutoRigController` singleton, gated on `hasRiggableSelection` — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category `ai.assist.auto_rig`. **Quality limits** (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. **Mixamo-style marker placement** (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — `AutoRig::humanoidMarkerOrder()`), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is `AutoRig::fitTemplateWithMarkers` and it does **coherent inference**, not per-marker patching: it runs `fitTemplate` for a proportional baseline (and to read the template's segment vectors / lateral offsets), then **resolves an anchor for every key joint** (Head, L/R Shoulder, L/R Hand, Hips, L/R UpLeg, L/R Knee) as *marked → inferred-from-marked-neighbours → template* and lays the dependent chains (spine, arms, legs) from those anchors — so a partial marker set yields an anatomically-sane skeleton instead of mixing marked anchors with stranded template joints (no shoulder-above-head). Inference: Hips ← midpoint of marked up-legs + template socket→pelvis rise; Head ← template offset above resolved Hips; UpLeg ← mirror the other up-leg across the pelvis, else pelvis + template socket offset; Shoulder ← along the live Hips→Head line at the template shoulder-height fraction + template lateral offset, else mirror the other; Hand ← shoulder + template arm vector (marked shoulder + skipped wrist still lays a full arm); Knee/foot ← clamped to the mesh AABB so an inferred leg never punches through the model: when the knee is skipped, the foot is dropped straight to the mesh FLOOR (`mn[up]`) below the up-leg and the knee placed halfway between (template thigh-vector extrapolation, which used to shoot feet past the lower limit, is only used for the small forward knee nudge); a marked knee keeps its position with the foot extrapolated below but still floor-clamped. Mirroring reflects across the sagittal plane (side axis auto-detected). An empty marker set early-returns `fitTemplate` unchanged; `report.markersApplied` counts only user-placed markers. (Legacy per-marker description retained below for the chain mechanics.) The old behaviour was: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→`layChain` lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→`layLeg` anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). `layChain` is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (`report.markersApplied` counts the placed ones; an empty marker set is bit-identical to `fitTemplate`). The viewport flow lives in `AutoRigController` (marker-session state machine: `beginMarkerPlacement`/`skipCurrentMarker`/`undoLastMarker`/`cancelMarkerPlacement`/`commitMarkerRig`); clicks are routed in by `TransformOperator::mousePressEvent` (checked **before** the knife/select paths when `markerMode()` is true), ray-cast to the mesh surface (`getCameraToViewportRay` → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow `PT_SPHERE` overlays. **The whole UI is inline in the Inspector's "Rigging" section** (`riggingToolsComponent` in `qml/PropertiesPanel.qml`) — there is no separate dialog (the old `AutoRigDialog.qml` was removed). It show/hides smartly: idle shows the two entry points ("Place markers…" / "Auto-Rig (template)"), a skin checkbox, and an "Advanced options" checkbox that reveals the template + up-axis pickers; while `markerMode` is active it swaps to the per-marker guidance label + Skip/Undo/Cancel/"Rig from markers" controls. Rig state + the `runAutoRig`/`runMarkerRig` helpers live on the `PropertiesPanel` root; the section's `onSectionVisibleChanged` cancels any active marker session if the section disappears (mode change / deselect / re-rig), replacing the dialog's old `onClosing` cancel. No CLI/MCP marker surface — guided placement is inherently interactive. - **QuadRetopo** (`src/QuadRetopo.h/cpp`, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with **zero new dependencies**: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default `maxAngleDeg=25°`), (2) quad shape (deviation of interior angles from 90°; default `shapeToleranceDeg=65°`), (3) aspect ratio (longest/shortest edge; default `maxAspectRatio=6.0`). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding `(opposing0, sharedA, opposing1, sharedB)`. Output goes through `EditableSubMesh::faces` → `triangulateFaces` (fan retri for GPU) → `writeNgonFacesToMesh` (n-gon binding for exporters / Edit Mode). **No new vertices** are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the `Algorithm` enum (only `TrianglePair` implemented; future `QuadriFlow` / `InstantMeshes` slot in here). Surfaced via `qtmesh retopo --target-faces N --max-angle DEG -o out`, MCP `retopologize`, and the **Material Mode → Mode Tools → "Quad Retopology…" button** (`qml/QuadRetopoDialog.qml`, driven by `QuadRetopoController` singleton). Sentry breadcrumb category `ai.assist.retopo`. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%. - **UvUnwrap** (`src/UvUnwrap.h/cpp`, issue #400): xatlas-backed automatic UV unwrap. xatlas is the MIT library Blender and Godot use under the hood — single-translation-unit `xatlas.cpp` vendored via FetchContent and wrapped in an inline `add_library(xatlas STATIC …)` target (no upstream CMake config). Pipeline: extract (positions, indices) per submesh → `xatlas::AddMesh` → `xatlas::Generate` → for each output mesh, rebuild a single-binding VertexData copying every source attribute from `xref` (input vertex id) and overwriting the target UV channel with `xatlas::Vertex::uv / atlas.{width,height}`. Skinned-mesh bone assignments survive the seam splits because we rebuild `SubMesh::BoneAssignmentList` against the new vertex IDs via xref; for shared-vertex meshes the source assignments come from `Mesh::getBoneAssignments()`, not `SubMesh::getBoneAssignments()`. Surfaced via `qtmesh uv --unwrap`/`--info`, MCP `auto_uv_unwrap`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `ai.assist.uv_unwrap`. The unwrap also erases `qtme.faces.` n-gon bindings (they reference source vertex IDs and become stale). **GUI-safe entry point** (`unwrapEntityToFile`): live skinned meshes cannot survive in-place vertex-data mutation because the active `Ogre::SkeletonInstance` caches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshots `vertexData` / `indexData` / `mBoneAssignments` / `blendIndexToBoneIndexMap` for every submesh + the mesh's shared maps, calls `unwrapEntityKeepingOriginals` (which deliberately leaks its own allocations rather than freeing the originals), exports the unwrapped result, then restores the snapshot pointer-for-pointer (deleting only the unwrap's leaked allocations) and pastes the index maps back directly — `_compileBoneAssignments` is NOT called on restore because it would re-pack BLEND_INDICES/WEIGHTS bytes against the live buffer and shatter the on-screen mesh. CLI path uses the destructive `unwrapEntity` since the process exits before rendering. - **ExportOptimizer** (`src/ExportOptimizer.h/cpp`, issue #399): Pipeline that runs `meshopt_optimizeVertexCache` → `meshopt_optimizeOverdraw` (threshold 1.05) → `meshopt_optimizeVertexFetchRemap` on every submesh of an entity. Surfaced through the **Inspector validation flow** — the "Optimize Geometry (cache + overdraw + fetch)" button in `PropertiesPanel.qml` runs it via `MeshValidator::optimizeVertexCache`. NOT hooked into `MeshImporterExporter::exporter` by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses `useSharedVertices` since remapping shared verts would scramble other submeshes' indices. `qtmesh info --json` includes `submeshAcmr[]` per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category `ai.assist.optimize_export`. diff --git a/src/AutoRig.cpp b/src/AutoRig.cpp index b6e49dbca..7519218d1 100644 --- a/src/AutoRig.cpp +++ b/src/AutoRig.cpp @@ -290,6 +290,20 @@ void layChain(std::vector& js, } } +// Find a placed joint's position by name; returns `fallback` if absent. +std::array jointPosOr(const std::vector& js, + const char* name, + const std::array& fallback) +{ + for (const auto& j : js) if (j.name == QLatin1String(name)) return j.pos; + return fallback; +} + +std::array add3(const std::array& a, const std::array& b) +{ return { a[0]+b[0], a[1]+b[1], a[2]+b[2] }; } +std::array sub3(const std::array& a, const std::array& b) +{ return { a[0]-b[0], a[1]-b[1], a[2]-b[2] }; } + } // namespace std::vector AutoRig::fitTemplateWithMarkers( @@ -299,7 +313,11 @@ std::vector AutoRig::fitTemplateWithMarkers( const Options& opts, int* outRecentered, int* outMarkersApplied) { - // Start from the proportional fit; markers refine it. + // Proportional baseline — gives sensible default joint positions AND the + // template relationships (segment vectors, lateral offsets) we use to + // INFER unmarked joints from marked ones, so a partial marker set produces + // a coherent skeleton (no shoulder-above-head etc.) instead of mixing + // marked anchors with stranded template joints. std::vector placed = fitTemplate(tmpl, verts, vertexCount, opts, outRecentered); if (outMarkersApplied) *outMarkersApplied = 0; @@ -308,115 +326,185 @@ std::vector AutoRig::fitTemplateWithMarkers( if (m.id == id && m.set) return &m; return nullptr; }; + + // No markers placed → the proportional fit is the answer, untouched (keeps + // the "empty marker set ≡ fitTemplate" contract; nothing to infer from). + bool anySet = false; + for (const auto& m : markers) if (m.set) { anySet = true; break; } + if (!anySet) return placed; + int applied = 0; - // Hips: anchor the pelvis directly, and carry the thigh roots - // (LeftUpLeg / RightUpLeg — children of Hips in the template) along with it - // by the same delta, so marking the hips moves the whole pelvis+thigh-root - // cluster as a unit instead of leaving the thighs floating at their - // template position. (An explicit L/R-hip marker below overrides its root.) - if (const Marker* m = get(MarkerId::Hips)) { - if (auto* hips = findJoint(placed, "Hips")) { - const std::array d = { m->pos[0] - hips->pos[0], - m->pos[1] - hips->pos[1], - m->pos[2] - hips->pos[2] }; - hips->pos = m->pos; - for (const char* leg : {"LeftUpLeg", "RightUpLeg"}) { - if (auto* j = findJoint(placed, leg)) - j->pos = { j->pos[0]+d[0], j->pos[1]+d[1], j->pos[2]+d[2] }; - } - ++applied; - } - } - // Chin: anchor Head, then lay the SPINE straight from the pelvis up to the - // head so the torso follows the marked hips↔chin span instead of leaving - // Spine/Chest/Neck stranded at their template heights. The spine joints are - // distributed evenly between Hips and Head by index (cartoon torsos vary a - // lot in length, so a proportional template guess is usually wrong). - if (const Marker* m = get(MarkerId::Chin)) { - if (auto* head = findJoint(placed, "Head")) { - head->pos = m->pos; - ++applied; - // Anchor at the (marked-or-template) Hips; lay Spine→Chest→Neck→Head. - if (auto* hips = findJoint(placed, "Hips")) { - // Spine chain joints in parent→child order, Head is the tip. - static const char* kSpine[] = - { "Spine", "Chest", "Neck" }; // between Hips and Head - const auto a = hips->pos; - const int last = static_cast(std::size(kSpine)) + 1; // +Head - for (int i = 0; i < static_cast(std::size(kSpine)); ++i) { - if (auto* j = findJoint(placed, kSpine[i])) - j->pos = lerp3(a, m->pos, - static_cast(i + 1) / last); - } - } else if (auto* neck = findJoint(placed, "Neck")) { - // No hips reference — fall back to the old neck lift. - if (auto* chest = findJoint(placed, "Chest")) - neck->pos = lerp3(chest->pos, m->pos, 0.5); - } + // Mesh AABB (mesh-local space, same coords the fit works in) — used to + // CLAMP inferred joints to the model's extent so an extrapolated limb + // (e.g. up-leg set but knee skipped) can't shoot a foot below the mesh. + std::array mn = { 1e30, 1e30, 1e30}; + std::array mx = {-1e30, -1e30, -1e30}; + for (int i = 0; i < vertexCount; ++i) { + for (int a = 0; a < 3; ++a) { + const double v = verts[3 * i + a]; + mn[a] = std::min(mn[a], v); + mx[a] = std::max(mx[a], v); } } - // Shoulders: anchor the arm-chain attach point. Applied BEFORE the wrist - // chains so layChain (which uses the shoulder as its fixed anchor) lays the - // arm out from the marked shoulder rather than the template one. A shoulder - // marker on its own (no wrist) still repositions the attach point. - if (const Marker* m = get(MarkerId::LeftShoulder)) { - if (auto* j = findJoint(placed, "LeftShoulder")) { j->pos = m->pos; ++applied; } - } - if (const Marker* m = get(MarkerId::RightShoulder)) { - if (auto* j = findJoint(placed, "RightShoulder")) { j->pos = m->pos; ++applied; } - } - // Arms: the wrist marker is the hand position. Lay the whole arm chain - // straight from the SHOULDER (anchor — its marked-or-template position) out - // to the marker — LeftShoulder → LeftArm → LeftForeArm → LeftHand(=marker) — - // distributing the upper-arm/forearm joints along the way so the entire - // arm reaches toward the wrist, not just the hand. - if (const Marker* m = get(MarkerId::LeftWrist)) { - layChain(placed, {"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand"}, m->pos); - ++applied; - } - if (const Marker* m = get(MarkerId::RightWrist)) { - layChain(placed, {"RightShoulder", "RightArm", "RightForeArm", "RightHand"}, m->pos); - ++applied; - } - // Hip sockets: anchor each thigh root (UpLeg) at its marker. Applied BEFORE - // the knee chains so layLeg lays the lower leg from the marked socket. This - // OVERRIDES the hips-carried position above, so an explicit hip marker wins - // (matters for cartoon models where the thighs splay out at odd angles a - // template/pelvis-carry can't capture). A hip marker on its own (no knee) - // still repositions the socket. - if (const Marker* m = get(MarkerId::LeftUpLeg)) { - if (auto* j = findJoint(placed, "LeftUpLeg")) { j->pos = m->pos; ++applied; } + + // ---- Template reference positions (the proportional fit) ------------- + const auto tHips = jointPosOr(placed, "Hips", {0,0,0}); + const auto tHead = jointPosOr(placed, "Head", tHips); + const auto tLSh = jointPosOr(placed, "LeftShoulder", tHips); + const auto tRSh = jointPosOr(placed, "RightShoulder", tHips); + const auto tLHand = jointPosOr(placed, "LeftHand", tLSh); + const auto tRHand = jointPosOr(placed, "RightHand", tRSh); + const auto tLUp = jointPosOr(placed, "LeftUpLeg", tHips); + const auto tRUp = jointPosOr(placed, "RightUpLeg", tHips); + const auto tLKnee = jointPosOr(placed, "LeftLeg", tLUp); + const auto tRKnee = jointPosOr(placed, "RightLeg", tRUp); + + // Reflect a point across the body's sagittal plane (the plane through Hips + // perpendicular to the side axis). Used to mirror a marked left limb onto + // an unmarked right one (and vice-versa). The side axis is whichever of the + // two non-up axes the template shoulders are most separated along. + const int up = std::clamp(opts.upAxis, 0, 2); + int sideAxis = (up == 0) ? 1 : 0; // first non-up axis + { + const int a1 = (up == 0) ? 1 : 0; + const int a2 = (up == 2) ? 1 : 2; + if (std::abs(tLSh[a2] - tRSh[a2]) > std::abs(tLSh[a1] - tRSh[a1])) + sideAxis = a2; } - if (const Marker* m = get(MarkerId::RightUpLeg)) { - if (auto* j = findJoint(placed, "RightUpLeg")) { j->pos = m->pos; ++applied; } + auto mirror = [&](std::array p, const std::array& center) { + p[sideAxis] = center[sideAxis] - (p[sideAxis] - center[sideAxis]); + return p; + }; + + // ---- Resolve anchor positions (marked → inferred → template) --------- + // Each `resolve` records whether a USER marker drove it (for applied count). + const Marker* mHead = get(MarkerId::Chin); + const Marker* mHips = get(MarkerId::Hips); + const Marker* mLSh = get(MarkerId::LeftShoulder); + const Marker* mRSh = get(MarkerId::RightShoulder); + const Marker* mLWr = get(MarkerId::LeftWrist); + const Marker* mRWr = get(MarkerId::RightWrist); + const Marker* mLUp = get(MarkerId::LeftUpLeg); + const Marker* mRUp = get(MarkerId::RightUpLeg); + const Marker* mLKn = get(MarkerId::LeftKnee); + const Marker* mRKn = get(MarkerId::RightKnee); + for (const Marker* m : {mHead,mHips,mLSh,mRSh,mLWr,mRWr,mLUp,mRUp,mLKn,mRKn}) + if (m) ++applied; + + // HIPS: marked → else from the up-legs (midpoint, lifted by the template + // socket→pelvis rise) → else template. + std::array pHips = tHips; + if (mHips) pHips = mHips->pos; + else if (mLUp && mRUp) { + pHips = { 0.5*(mLUp->pos[0]+mRUp->pos[0]), + 0.5*(mLUp->pos[1]+mRUp->pos[1]), + 0.5*(mLUp->pos[2]+mRUp->pos[2]) }; + const auto lift = sub3(tHips, { 0.5*(tLUp[0]+tRUp[0]), + 0.5*(tLUp[1]+tRUp[1]), + 0.5*(tLUp[2]+tRUp[2]) }); + pHips = add3(pHips, lift); } - // Legs: the knee marker is the knee (LeftLeg) position. The thigh root - // (UpLeg) is the anchor — it sits at the hip socket (its marked position, - // else carried by the hips marker, else the template fit). Anchor the knee - // at the marker - // and continue the foot below it along the thigh→knee direction (~equal - // length), so the WHOLE leg — thigh root → knee → foot — lays out to follow - // the marked hips + knee instead of leaving the upper leg at its template - // position. (UpLeg→Leg is a 2-joint segment: anchor + tip, so layChain - // would just set the knee; we keep the explicit form to also place the - // extrapolated foot.) - auto layLeg = [&](const char* up, const char* knee, const char* foot, - const std::array& kneePos) { - AutoRig::Joint* hip = findJoint(placed, up); - AutoRig::Joint* kn = findJoint(placed, knee); - if (!hip || !kn) return; - kn->pos = kneePos; - // Foot continues below the knee, same direction as thigh→knee, ~equal len. - if (auto* ft = findJoint(placed, foot)) { - const auto d = std::array{ kneePos[0]-hip->pos[0], - kneePos[1]-hip->pos[1], - kneePos[2]-hip->pos[2] }; - ft->pos = { kneePos[0] + d[0], kneePos[1] + d[1], kneePos[2] + d[2] }; + + // HEAD (chin): marked → else template lifted to keep the marked-hips offset. + std::array pHead = mHead ? mHead->pos : add3(pHips, sub3(tHead, tHips)); + + // UP-LEGS: marked → else mirror the other marked one across the pelvis → + // else pelvis + template socket offset. + std::array pLUp, pRUp; + pLUp = mLUp ? mLUp->pos : (mRUp ? mirror(mRUp->pos, pHips) : add3(pHips, sub3(tLUp, tHips))); + pRUp = mRUp ? mRUp->pos : (mLUp ? mirror(mLUp->pos, pHips) : add3(pHips, sub3(tRUp, tHips))); + + // SHOULDERS: marked → else mirror the other → else from the spine: place at + // the template's shoulder-height fraction along the live Hips→Head line, + // plus the template lateral offset (so chin+hips imply the shoulders). + auto shoulderFromSpine = [&](const std::array& tSh) { + const double denomUp = (tHead[up] - tHips[up]); + const double f = std::abs(denomUp) > 1e-9 + ? (tSh[up] - tHips[up]) / denomUp : 0.78; + std::array p = lerp3(pHips, pHead, std::clamp(f, 0.0, 1.0)); + // lateral / depth offset of the template shoulder from the spine line + const std::array tSpineAtSh = lerp3(tHips, tHead, std::clamp(f,0.0,1.0)); + const auto off = sub3(tSh, tSpineAtSh); + return add3(p, off); + }; + std::array pLSh, pRSh; + pLSh = mLSh ? mLSh->pos : (mRSh ? mirror(mRSh->pos, pHead) : shoulderFromSpine(tLSh)); + pRSh = mRSh ? mRSh->pos : (mLSh ? mirror(mLSh->pos, pHead) : shoulderFromSpine(tRSh)); + + // HANDS (wrist): marked → else shoulder + template arm vector (so a marked + // shoulder with a skipped wrist still lays a full arm reaching out). + std::array pLHand, pRHand; + pLHand = mLWr ? mLWr->pos : add3(pLSh, sub3(tLHand, tLSh)); + pRHand = mRWr ? mRWr->pos : add3(pRSh, sub3(tRHand, tRSh)); + + // KNEES + FEET: resolve both, clamped to the mesh's lower extent so an + // inferred leg never punches through the bottom of the model. + // * knee marked → knee at the marker, foot extrapolated below + // (knee + thigh→knee), then clamped to the floor. + // * knee unmarked → drop the foot to the mesh FLOOR (mn[up]) straight + // below the up-leg, and put the knee halfway between the + // up-leg and that foot. (Template thigh-vector + // extrapolation is what shot feet past the mesh limit; + // anchoring the foot to the floor fixes that.) + const double floorUp = mn[up]; + auto resolveLeg = [&](const std::array& upPos, + const std::array& tKnee, + const std::array& tUp, + const Marker* kneeMk, + std::array& knee, + std::array& foot) { + if (kneeMk) { + knee = kneeMk->pos; + foot = add3(knee, sub3(knee, upPos)); // continue below the knee + } else { + // Foot straight below the up-leg, sitting on the mesh floor. + foot = upPos; foot[up] = floorUp; + knee = { 0.5*(upPos[0]+foot[0]), + 0.5*(upPos[1]+foot[1]), + 0.5*(upPos[2]+foot[2]) }; + // Nudge the knee slightly forward (template thigh→knee in-plane + // direction) so it isn't a perfectly straight, lockable line. + const auto tIn = sub3(tKnee, tUp); + for (int a = 0; a < 3; ++a) if (a != up) knee[a] += tIn[a] * 0.25; } + // Never let the foot go below the mesh floor (clamp the up coord). + if (foot[up] < floorUp) foot[up] = floorUp; + // Keep the knee strictly between the up-leg and the foot in up-coord. + const double lo = std::min(upPos[up], foot[up]); + const double hi = std::max(upPos[up], foot[up]); + knee[up] = std::clamp(knee[up], lo, hi); }; - if (const Marker* m = get(MarkerId::LeftKnee)) { layLeg("LeftUpLeg", "LeftLeg", "LeftFoot", m->pos); ++applied; } - if (const Marker* m = get(MarkerId::RightKnee)) { layLeg("RightUpLeg", "RightLeg", "RightFoot", m->pos); ++applied; } + std::array pLKnee, pLFoot, pRKnee, pRFoot; + resolveLeg(pLUp, tLKnee, tLUp, mLKn, pLKnee, pLFoot); + resolveLeg(pRUp, tRKnee, tRUp, mRKn, pRKnee, pRFoot); + + // ---- Write the resolved anchors back, then lay the dependent chains -- + auto setJoint = [&](const char* name, const std::array& p) { + if (auto* j = findJoint(placed, name)) j->pos = p; + }; + setJoint("Hips", pHips); + setJoint("Head", pHead); + setJoint("LeftShoulder", pLSh); + setJoint("RightShoulder", pRSh); + setJoint("LeftUpLeg", pLUp); + setJoint("RightUpLeg", pRUp); + + // Spine: distribute Spine/Chest/Neck evenly between Hips and Head. + { + static const char* kSpine[] = { "Spine", "Chest", "Neck" }; + const int last = static_cast(std::size(kSpine)) + 1; // +Head + for (int i = 0; i < static_cast(std::size(kSpine)); ++i) + setJoint(kSpine[i], lerp3(pHips, pHead, + static_cast(i + 1) / last)); + } + // Arms: lay the full chain shoulder→arm→forearm→hand toward the resolved hand. + layChain(placed, {"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand"}, pLHand); + layChain(placed, {"RightShoulder", "RightArm", "RightForeArm", "RightHand"}, pRHand); + + // Legs: write the resolved (and floor-clamped) knee + foot anchors. + setJoint("LeftLeg", pLKnee); setJoint("LeftFoot", pLFoot); + setJoint("RightLeg", pRKnee); setJoint("RightFoot", pRFoot); if (outMarkersApplied) *outMarkersApplied = applied; return placed; diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp index 8787f7b8f..8fe2a22fe 100644 --- a/src/AutoRig_test.cpp +++ b/src/AutoRig_test.cpp @@ -427,3 +427,150 @@ TEST(AutoRigMarkers, ShoulderMarkerAnchorsAttachAndArmLaysFromIt) } EXPECT_LT(jdist(marked[iHand], AutoRig::Joint{"", -1, wrist.pos}), 1e-6); } + +// ---- Inference: unmarked joints derived from marked neighbours ---------- + +TEST(AutoRigMarkers, ShoulderInferredFromHipsAndChinSpan) +{ + // Mark only chin + hips (no shoulders): each shoulder should be inferred + // ALONG the hips→head line (never above the head), not left at template. + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iLSh = jindex(base, "LeftShoulder"); + const int iHead = jindex(base, "Head"); + const int iHips = jindex(base, "Hips"); + if (iLSh < 0 || iHead < 0 || iHips < 0) GTEST_SKIP() << "no spine/shoulder"; + + AutoRig::Marker hips; + hips.id = AutoRig::MarkerId::Hips; hips.set = true; hips.pos = {0.0, 0.80, 0.0}; + AutoRig::Marker chin; + chin.id = AutoRig::MarkerId::Chin; chin.set = true; chin.pos = {0.0, 2.00, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {hips, chin}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + // Shoulder up-coord lies strictly between hips and head (axis 1 = +Y). + EXPECT_GT(m[iLSh].pos[1], hips.pos[1]); + EXPECT_LT(m[iLSh].pos[1], chin.pos[1]); +} + +TEST(AutoRigMarkers, HipsInferredFromUpLegsWhenUnmarked) +{ + // Mark only the two up-legs (no hips): pelvis should land at their midpoint + // plus the template socket→pelvis rise — not at the template hips. + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iHips = jindex(base, "Hips"); + if (iHips < 0) GTEST_SKIP() << "no hips"; + + AutoRig::Marker lu, ru; + lu.id = AutoRig::MarkerId::LeftUpLeg; lu.set = true; lu.pos = {0.30, 0.70, 0.0}; + ru.id = AutoRig::MarkerId::RightUpLeg; ru.set = true; ru.pos = {-0.30, 0.70, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {lu, ru}, opts, nullptr, &applied); + EXPECT_EQ(applied, 2); + // Pelvis centred between the sockets (x ≈ 0) and lifted above them (y > 0.70). + EXPECT_LT(std::abs(m[iHips].pos[0] - 0.0), 1e-6); + EXPECT_GT(m[iHips].pos[1], 0.70); +} + +TEST(AutoRigMarkers, UnmarkedShoulderMirrorsMarkedOne) +{ + // Mark one shoulder; the other should mirror across the body (opposite + // side-axis sign, ~symmetric), not stay at the template. + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iLSh = jindex(base, "LeftShoulder"); + const int iRSh = jindex(base, "RightShoulder"); + if (iLSh < 0 || iRSh < 0) GTEST_SKIP() << "no shoulders"; + + AutoRig::Marker ls; + ls.id = AutoRig::MarkerId::LeftShoulder; ls.set = true; ls.pos = {0.55, 1.50, 0.10}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {ls}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + EXPECT_LT(jdist(m[iLSh], AutoRig::Joint{"", -1, ls.pos}), 1e-6); + // Right shoulder is on the opposite side (x sign flipped relative to L). + EXPECT_LT(m[iRSh].pos[0], 0.0); + // Same height + depth as the marked one (pure mirror across the side axis). + EXPECT_LT(std::abs(m[iRSh].pos[1] - ls.pos[1]), 1e-6); +} + +TEST(AutoRigMarkers, ShoulderMarkedWristSkippedStillLaysArm) +{ + // Shoulder marked, wrist skipped: the hand should reach out from the marked + // shoulder by the template arm vector (not collapse onto the shoulder). + auto cloud = uprightCloud(); + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iSh = jindex(base, "LeftShoulder"); + const int iHand = jindex(base, "LeftHand"); + if (iSh < 0 || iHand < 0) GTEST_SKIP() << "no left arm"; + const double tArmLen = std::sqrt( + std::pow(base[iHand].pos[0]-base[iSh].pos[0],2) + + std::pow(base[iHand].pos[1]-base[iSh].pos[1],2) + + std::pow(base[iHand].pos[2]-base[iSh].pos[2],2)); + + AutoRig::Marker ls; + ls.id = AutoRig::MarkerId::LeftShoulder; ls.set = true; ls.pos = {0.60, 1.55, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {ls}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + EXPECT_LT(jdist(m[iSh], AutoRig::Joint{"", -1, ls.pos}), 1e-6); + // Hand is ~one template arm-length away from the marked shoulder. + const double handLen = std::sqrt( + std::pow(m[iHand].pos[0]-ls.pos[0],2) + + std::pow(m[iHand].pos[1]-ls.pos[1],2) + + std::pow(m[iHand].pos[2]-ls.pos[2],2)); + EXPECT_GT(handLen, tArmLen * 0.5); +} + +TEST(AutoRigMarkers, UpLegSetKneeSkippedClampsFootToMeshFloor) +{ + // Up-leg marked, knee skipped: the foot must land at (not below) the mesh + // floor, and the knee must sit between the up-leg and the foot. Previously + // the template thigh-vector extrapolation pushed the foot past the mesh. + auto cloud = uprightCloud(); // y in [0, 2] → floor = 0 + auto tmpl = AutoRig::templateJoints(AutoRig::Template::Humanoid); + AutoRig::Options opts; + auto base = AutoRig::fitTemplate(tmpl, cloud.data(), + static_cast(cloud.size() / 3), opts); + const int iUp = jindex(base, "LeftUpLeg"); + const int iKnee = jindex(base, "LeftLeg"); + const int iFoot = jindex(base, "LeftFoot"); + if (iUp < 0 || iKnee < 0 || iFoot < 0) GTEST_SKIP() << "no left leg"; + + AutoRig::Marker up; + up.id = AutoRig::MarkerId::LeftUpLeg; up.set = true; up.pos = {0.30, 0.90, 0.0}; + + int applied = 0; + auto m = AutoRig::fitTemplateWithMarkers(tmpl, cloud.data(), + static_cast(cloud.size() / 3), {up}, opts, nullptr, &applied); + EXPECT_EQ(applied, 1); + + const double floor = 0.0; + // Foot sits on (not below) the mesh floor. + EXPECT_GE(m[iFoot].pos[1], floor - 1e-6); + EXPECT_LT(std::abs(m[iFoot].pos[1] - floor), 1e-6); + // Knee strictly between the up-leg (0.90) and the foot (0.0) in height. + EXPECT_LT(m[iKnee].pos[1], m[iUp].pos[1]); + EXPECT_GT(m[iKnee].pos[1], m[iFoot].pos[1]); +} From 75e7b68a06dc478748b47451962cfbe8b839eaf1 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 24 Jun 2026 15:37:44 -0400 Subject: [PATCH 24/24] fix(#407): update stale 6-marker test to 10; bust stale macOS assimp cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AutoRigMarkers.OrderAndLabelsAreStable still asserted 6 markers (and order[5]==Hips) from the first marker commit; the set grew to 10 (added L/R shoulder + L/R hip). unit-tests-linux caught it. Assert 10 and use front()/back() so the count is the single source of truth. - Bump MACOS_CACHE_VERSION sdkpin1→sdkpin2: the assimp macOS cache was built under Xcode 26.5 and baked .../MacOSX26.5.sdk/.../libz.tbd into Codec_Assimp, so OGRE's cache-miss rebuild under the pinned 26.3 failed with "No rule to make target .../libz.tbd". Busting the cache rebuilds assimp under the pinned SDK. (Pre-existing CI infra issue, not from this feature.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 7 +++++-- src/AutoRig_test.cpp | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba8aeff43..e6634d446 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,8 +28,11 @@ env: # the selected Xcode (xcode-select alone didn't stop find_package(ZLIB) from # picking xcrun's default 26.5 SDK). Bump this whenever the pinned Xcode/SDK # changes so the SDK is rebuilt against it and stale libz.tbd paths are - # discarded. (sdkpin1 = first build under the SDKROOT-pinned environment.) - MACOS_CACHE_VERSION: 'sdkpin1' + # discarded. (sdkpin1 = first build under the SDKROOT-pinned environment; + # sdkpin2 = bust the stale assimp cache that still baked the Xcode 26.5 + # libz.tbd path — "No rule to make target .../MacOSX26.5.sdk/.../libz.tbd" + # when OGRE consumed it under the pinned 26.3.) + MACOS_CACHE_VERSION: 'sdkpin2' jobs: # send-slack-notification: diff --git a/src/AutoRig_test.cpp b/src/AutoRig_test.cpp index 8fe2a22fe..28164f06f 100644 --- a/src/AutoRig_test.cpp +++ b/src/AutoRig_test.cpp @@ -178,9 +178,9 @@ int jindex(const std::vector& js, const QString& name) TEST(AutoRigMarkers, OrderAndLabelsAreStable) { const auto order = AutoRig::humanoidMarkerOrder(); - ASSERT_EQ(order.size(), 6u); - EXPECT_EQ(order[0], AutoRig::MarkerId::Chin); - EXPECT_EQ(order[5], AutoRig::MarkerId::Hips); + ASSERT_EQ(order.size(), 10u); + EXPECT_EQ(order.front(), AutoRig::MarkerId::Chin); // top-down: chin first + EXPECT_EQ(order.back(), AutoRig::MarkerId::Hips); // pelvis last for (auto id : order) EXPECT_FALSE(AutoRig::markerLabel(id).isEmpty()); }