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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,10 @@ message(STATUS "xatlas enabled (auto UV unwrap)")
FetchContent_Declare(
stb
GIT_REPOSITORY https://github.com/nothings/stb.git
GIT_TAG 31c1ad37456438565541f4919958214b6e762fb4
# Re-pinned 2026-08: the previous pin 31c1ad3745… stopped fetching in CI
# ("fatal: unable to read tree" on every platform during the shallow clone).
# 2c980bb… is the current upstream master head and clones cleanly.
GIT_TAG 2c980bb59875b0d32144a71867fbdebb2f77cd20
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(stb)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ Split View|Skeleton Animation Controls
- **Paint tools** — vertex paint, texture paint (BaseColor), bake vertex colors to texture with seam dilation
- **Material editor** — visual editing with AI-assisted generation; PBR + HDR/IBL materials look correct on first import (bundled studio HDRI, ACES tonemap, env intensity/tint per material)
- **Skeleton inspection** — bone weights, debug overlays, animation preview
- **AI part segmentation & PartOps** — detect a character's parts (head/torso/arms/legs), then **split** into named submeshes, **explode** into separate scene nodes, **join** them back, or **solidify** thin-shell parts; GUI + `qtmesh segment --split-parts / --explode-parts` + MCP (`split_mesh_by_segments`, `explode_mesh_parts`, `join_mesh_parts`) — see [docs/PART_OPS.md](docs/PART_OPS.md)
- **Scene management** — duplicate (Ctrl+D), group (Ctrl+G), snap, pivot modes
- **Performance capture** — video/webcam → facial morph animation (ARKit blendshapes), head pose, and full-body skeletal capture onto humanoid rigs; live preview + record in the editor, `qtmesh mocap` on the CLI (`-DENABLE_MOCAP` builds)
- **AI chat** — natural language scene editing via local LLMs
Expand Down
99 changes: 99 additions & 0 deletions docs/PART_OPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# PartOps — AI segmented parts, split, explode & join

PartOps (epic #859) turns QtMeshEditor's AI **mesh segmentation** into real mesh
authoring operations: take one fused character mesh, detect its parts
(head / torso / arms / legs …), and **split**, **explode**, **join**, or
**solidify** them. Works in the GUI, the `qtmesh` CLI, and over MCP — all three
produce equivalent results and are undoable in the editor.

> Segmentation itself (the model + the "Select by Part" Edit-Mode tool) is
> documented in [MESH_SEGMENTATION_STRATEGY.md](MESH_SEGMENTATION_STRATEGY.md).
> PartOps is the *authoring* layer on top of it.

## The operations

| Op | What it does |
|---|---|
| **Split into Parts** | Segments the mesh and replaces it with one **named submesh per part** (`head`, `torso`, `left_arm`, …). Boundary vertices are duplicated so parts are independent; normals / UVs / colours / tangents / skeleton + bone weights are preserved (skinned characters stay riggable). |
| **Explode Parts** | Splits every submesh of a multi-part mesh into its own **scene node**, offset outward from the assembly centre — an exploded view you can edit part-by-part. |
| **Join Parts** | Merges 2+ part entities back into **one fused mesh**, baking each part's world transform into its geometry. Same-material submeshes coalesce. |
| **Solidify** (opt-in) | Gives each part real **wall volume**. Thin game-asset shells are single-sided surfaces with no thickness, so an exploded part exposes its hollow interior at the cut; solidify offsets an inner shell + stitches a wall so the cut shows a solid cross-section, and seals each part watertight. |

## GUI

Object mode → Inspector:

- **Split into Parts (AI)** section: pick a *Category* (auto / body / vegetation /
vehicle / building), tick **AI assisted** (uncheck for the offline
geometric / rig-prior path), optionally tick **Solidify thin shells**, then
**Split into Parts**.
- **Explode / Join Parts** section: set the **Explode distance** and click
**Explode Parts** (needs one multi-part mesh selected), or select 2+ part nodes
and click **Join Parts**.

Every operation is a single undo step (`Ctrl+Z`).

## CLI (`qtmesh segment`)

```bash
# Split into named per-part submeshes
qtmesh segment model.fbx --split-parts -o parts.glb
qtmesh segment model.fbx --split-parts --no-model -o parts.glb # offline path
qtmesh segment model.fbx --split-parts --solidify -o parts.glb # + wall volume

# Explode into a multi-node scene (splits first)
qtmesh segment model.fbx --explode-parts -o scene.glb
qtmesh segment model.fbx --explode-parts --explode-distance 0.25 --solidify -o scene.glb

# Just dump the labels (no geometry change)
qtmesh segment model.fbx --write-labels labels.json # schema qtmesh-partops-labels-v1
qtmesh segment model.fbx --json # per-part vertex/face counts
```

`--explode-distance` is a multiplier on the assembly diagonal (default `0.15`;
`0` = parts coincident). Add `--json` to any command for a structured report
(part names, created counts, distance). Join is GUI/MCP only (it needs several
part entities at once, which doesn't fit the single-input `segment` CLI).

## MCP

| Tool | Args | Returns |
|---|---|---|
| `segment_mesh` | `entity_name?`, `no_model?`, `category?`, `up_axis?` | per-part counts + `face_labels` (always) |
| `split_mesh_by_segments` | `entity_name?`, `no_model?`, `category?`, `up_axis?`, `solidify?` | created submesh count + part names |
| `explode_mesh_parts` | `entity_name?`, `distance?` | exploded part count |
| `join_mesh_parts` | `entity_names?` (omit → all mesh entities) | joined part count + created submesh count |

All operate on the live editor scene through the same undoable commands as the
GUI buttons, so `Ctrl+Z` in the editor undoes an MCP-driven split/explode/join.

## Limitations

- **Body-centric labels.** The default part vocabulary is humanoid
(head / torso / L+R arm / L+R leg). Non-body categories (vegetation / vehicle /
building) have their own label sets — pass `--category` / `category`. See the
segmentation strategy doc for the full vocabulary.
- **Model-unit dimensions.** Explode distance and solidify thickness are in the
mesh's own units (relative to its bounding-box diagonal), not millimetres.
- **Join yields static geometry.** Skeletons are **not** reconciled across parts,
so joining a skinned character produces a static mesh (bone weights are
dropped). Split → edit → **join** is a geometry workflow, not a rigging one.
- **Thin shells look hollow at a cut** unless you **Solidify** — capping the cut
ring alone is watertight but a zero-thickness game shell still shows its inner
back-wall. Solidify is the fix.
- **No 3D-print alignment pegs / boolean cutting.** An earlier attempt at
dowel/socket connectors was removed: there is no safe flat cut plane through an
organic AI-segmented joint (a plane through a hip seam also slices the torso),
and the leading image-to-3D tools (Meshy / Tripo) cut organic seams but ship no
discrete pegs either. Out of scope for this epic.

## Export

Split/exploded parts round-trip through **FBX** (submesh boundaries + part names
preserved) and **glTF/glb** (same-material parts coalesce; a multi-node explode
exports as a multi-node scene). STL export works for single solid parts.

## Telemetry

Sentry breadcrumbs: `mesh.parts.segment_preview`, `mesh.parts.split_segments`,
`mesh.parts.explode`, `mesh.parts.join`.
108 changes: 107 additions & 1 deletion src/CLIPipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
#include "MeshSegmenter.h"
#include "SubMeshOps.h"
#include "PartOpsMesh.h"
#include "PartOpsScene.h"
#include "MeshDecimator.h"
#include "EditableMesh.h"
#include "TexturePaintBuffer.h"
Expand Down Expand Up @@ -10357,6 +10358,8 @@
QString writeLabelsPath; // PartOps #864: dump face/vertex labels to JSON
QString outputPath; // PartOps #864: --split-parts output mesh
bool splitParts = false; // PartOps #861/#864
bool explodeParts = false; // PartOps #864: split then explode into N nodes
float explodeDistance = 0.15f; // PartOps #864
bool solidify = false; // #863 follow-up: give thin-shell parts wall volume
bool jsonOutput = false;
bool noModel = false;
Expand All @@ -10371,6 +10374,20 @@
if (arg == "--no-model") { noModel = true; continue; }
if (arg == "--no-island-cleanup") { noIslandCleanup = true; continue; }
if (arg == "--split-parts") { splitParts = true; continue; }
if (arg == "--explode-parts") { explodeParts = true; continue; }
if (arg == "--explode-distance") {
if (i + 1 >= argc) {
err() << "Error: --explode-distance requires a value." << Qt::endl;
return 2;
}
bool okv = false;
explodeDistance = QString::fromLocal8Bit(argv[++i]).toFloat(&okv);
if (!okv || explodeDistance < 0.0f) {
err() << "Error: --explode-distance must be a non-negative number." << Qt::endl;
return 2;
}
continue;
}
if (arg == "--solidify") { solidify = true; continue; }
if (arg == "--write-labels") {
if (i + 1 >= argc) {
Expand Down Expand Up @@ -10433,7 +10450,8 @@
"[--category auto|body|vegetation|vehicle|building] "
"[--no-island-cleanup] "
"[--dump-training-data <out.json>] [--write-labels <out.json>] "
"[--split-parts [--solidify] -o <out.glb>]" << Qt::endl;
"[--split-parts [--solidify] -o <out.glb>] "
"[--explode-parts [--explode-distance <d>] [--solidify] -o <scene.glb>]" << Qt::endl;
return 2;
}
QFileInfo fi(inputPath);
Expand All @@ -10442,6 +10460,12 @@
err() << "Error: --split-parts requires -o <output mesh>." << Qt::endl;
return 2;
}
// Fail fast BEFORE the (possibly model-downloading) segmentation, same as
// --split-parts above (CodeRabbit).
if (explodeParts && outputPath.isEmpty()) {
err() << "Error: --explode-parts requires -o <output scene>." << Qt::endl;
return 2;
}
if (!initOgreHeadless()) return 1;

SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.segment"),
Expand Down Expand Up @@ -10685,6 +10709,88 @@
return 0; // split path produces its own output; skip the label dump below
}

// --- PartOps: split then EXPLODE into separate nodes (#864) -------------
if (explodeParts) {
// (-o was validated up-front, before segmentation.)
// 1) Split the source mesh into per-part submeshes (one fused entity).
auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels);
SubMeshOps::SplitOptions sopts;
sopts.solidifyParts = solidify;
PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity(
entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString());
if (!so.ok) {
err() << "Error: split failed — "
<< (so.error.isEmpty() ? QStringLiteral("unknown") : so.error) << Qt::endl;
return 1;
}
auto* mgr = Manager::getSingletonPtr();
Ogre::SceneNode* srcNode = mgr ? mgr->addSceneNode("PartOpsExplodeSrc") : nullptr;
Ogre::Entity* splitEnt = (srcNode && mgr) ? mgr->createEntity(srcNode, so.mesh) : nullptr;
if (!splitEnt) {
err() << "Error: could not build scene node for split mesh." << Qt::endl;
return 1;
}
// 2) Explode: build one single-submesh mesh + outward offset per part.
PartOpsScene::ExplodeResult ex =
PartOpsScene::explodeEntity(splitEnt, explodeDistance,
fi.completeBaseName().toStdString() + "_part");
if (!ex.ok) {
err() << "Error: explode failed — "
<< (ex.error.isEmpty() ? QStringLiteral("unknown") : ex.error) << Qt::endl;
return 1;
}
// 3) Remove BOTH the temporary split node AND the ORIGINAL imported
// source node — sceneExporter walks every entity-bearing node, so
// leaving the original in the scene would overlay an intact un-exploded
// mesh on top of the exploded parts, doubling the geometry (CodeRabbit).
Ogre::SceneNode* origNode = entity ? entity->getParentSceneNode() : nullptr;
mgr->destroyAllAttachedMovableObjects(srcNode);
mgr->destroySceneNode(srcNode);
if (origNode) {
mgr->destroyAllAttachedMovableObjects(origNode);
mgr->destroySceneNode(origNode);
}
QStringList partNodeNames;
int idx = 0;
for (const PartOpsScene::ExplodePart& p : ex.parts) {
Ogre::SceneNode* pn =
mgr->addSceneNode(QString("PartOpsExplode_%1_%2").arg(idx++).arg(p.name));
if (!pn) continue;
pn->setPosition(p.offset);
mgr->createEntity(pn, p.mesh);
partNodeNames << p.name;
}
// 4) Export the whole multi-node scene. sceneExporter/sceneImporter is the
// matched pair for multi-entity glTF scenes.
const QString outUri = QFileInfo(outputPath).absoluteFilePath();
if (MeshImporterExporter::sceneExporter(outUri) != 0) {

Check warning on line 10766 in src/CLIPipeline.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "outUri" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ_Db1V5P1NBD9GnqVUK&open=AZ_Db1V5P1NBD9GnqVUK&pullRequest=938
Comment thread
fernandotonon marked this conversation as resolved.
err() << "Error: scene export failed for " << outputPath << Qt::endl;
return 1;
}
SentryReporter::addBreadcrumb(
QStringLiteral("mesh.parts.explode"),
QStringLiteral("parts=%1 distance=%2")
.arg(partNodeNames.size()).arg(explodeDistance));
if (jsonOutput) {
QJsonObject root;
root["mesh"] = fi.fileName();
root["output"] = QFileInfo(outputPath).fileName();
root["explodedParts"] = static_cast<int>(partNodeNames.size());
root["explodeDistance"] = explodeDistance;
QJsonArray pn;
for (const QString& n : partNodeNames) pn.append(n);
root["partNames"] = pn;
cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)) + "\n");
} else {
cliWrite(QString("Exploded %1 into %2 parts (distance %3) → %4\n")
.arg(fi.fileName()).arg(partNodeNames.size())
.arg(explodeDistance).arg(QFileInfo(outputPath).fileName()));
for (const QString& n : partNodeNames)
cliWrite(QString(" %1\n").arg(n));
}
return 0;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (jsonOutput) {
QJsonObject root;
root["mesh"] = fi.fileName();
Expand Down
86 changes: 86 additions & 0 deletions src/CLIPipeline_cmdsplitparts_coverage_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,92 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitPartsRequiresOutput)
EXPECT_EQ(2, CLIPipeline::cmdSegment(args.argc(), args.argv()));
}

// --explode-parts without -o is a usage error (exit 2). #864.
TEST_F(CLIPipelineCmdSplitPartsCoverageTest, ExplodePartsRequiresOutput)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString mesh = generatedMesh(tmp);
ASSERT_FALSE(mesh.isEmpty());
const QByteArray in = mesh.toUtf8();
SplitArgv args({"qtmesh", "segment", in.constData(), "--no-model", "--explode-parts"});
EXPECT_EQ(2, CLIPipeline::cmdSegment(args.argc(), args.argv()));
}

// --explode-distance rejects a negative / non-numeric value (exit 2). #864.
TEST_F(CLIPipelineCmdSplitPartsCoverageTest, ExplodeDistanceRejectsBadValue)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString mesh = generatedMesh(tmp);
ASSERT_FALSE(mesh.isEmpty());
const QByteArray in = mesh.toUtf8();
SplitArgv args({"qtmesh", "segment", in.constData(), "--no-model",
"--explode-parts", "--explode-distance", "-1", "-o", "/tmp/x.glb"});
EXPECT_EQ(2, CLIPipeline::cmdSegment(args.argc(), args.argv()));
}

// --explode-parts on a rigged humanoid: splits, offsets each part outward, and
// writes a MULTI-NODE scene glTF. We parse the .glb JSON chunk directly (no
// reimport — that path merges same-material nodes and pulls in scene lights) and
// assert the scene has multiple NODES, more than one of which carries a non-zero
// translation (the outward explode offset). #864.
TEST_F(CLIPipelineCmdSplitPartsCoverageTest, ExplodeWritesMultiNodeScene)
{
const QString fixture = riggedFixture();
if (fixture.isEmpty())
GTEST_SKIP() << "rigged fixture not present";

QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString explGlb = QDir(tmp.path()).filePath("exploded.glb");

clearScene();
const QByteArray in = fixture.toUtf8();
const QByteArray out = explGlb.toUtf8();
SplitArgv a({"qtmesh", "segment", in.constData(), "--no-model",
"--explode-parts", "--explode-distance", "0.3", "-o", out.constData()});
ASSERT_EQ(0, CLIPipeline::cmdSegment(a.argc(), a.argv()));
ASSERT_TRUE(QFile::exists(explGlb));

// Read the .glb JSON chunk (12-byte header, then [u32 len][u32 type][data];
// the first chunk, type 0x4E4F534A "JSON", holds the glTF document).
QFile f(explGlb);
ASSERT_TRUE(f.open(QIODevice::ReadOnly));
const QByteArray blob = f.readAll();
ASSERT_GT(blob.size(), 20);
ASSERT_EQ(blob.left(4), QByteArray("glTF"));
auto u32 = [&](int off) {
return static_cast<quint32>(static_cast<quint8>(blob[off]))
| (static_cast<quint32>(static_cast<quint8>(blob[off + 1])) << 8)
| (static_cast<quint32>(static_cast<quint8>(blob[off + 2])) << 16)
| (static_cast<quint32>(static_cast<quint8>(blob[off + 3])) << 24);
};
const quint32 chunkLen = u32(12);
const quint32 chunkType = u32(16);
ASSERT_EQ(chunkType, 0x4E4F534Au) << "first glb chunk must be JSON";
const QByteArray jsonBytes = blob.mid(20, static_cast<int>(chunkLen));
QJsonParseError perr{};
QJsonDocument doc = QJsonDocument::fromJson(jsonBytes, &perr);
ASSERT_EQ(perr.error, QJsonParseError::NoError) << perr.errorString().toStdString();
const QJsonObject gltf = doc.object();

const QJsonArray nodes = gltf.value("nodes").toArray();
EXPECT_GT(nodes.size(), 1) << "explode should write more than one scene node";

// More than one node carries a non-zero translation (the explode offsets).
int offsetNodes = 0;
for (const QJsonValue& nv : nodes) {
const QJsonArray t = nv.toObject().value("translation").toArray();
if (t.size() == 3) {
const double m = std::abs(t[0].toDouble()) + std::abs(t[1].toDouble())
+ std::abs(t[2].toDouble());
if (m > 1e-4) ++offsetNodes;
}
}
EXPECT_GT(offsetNodes, 1) << "exploded part nodes should carry outward offsets";
}

// --write-labels dumps a valid labels JSON with the documented schema + arrays.
TEST_F(CLIPipelineCmdSplitPartsCoverageTest, WriteLabelsProducesSchemaJson)
{
Expand Down
Loading
Loading