From 1c4ac5cf1244092451318017dac12de45626dbca Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 2 Aug 2026 11:47:24 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(#864/#865):=20PartOps=20Slices=20E+F?= =?UTF-8?q?=20=E2=80=94=20explode/join=20CLI+MCP=20parity=20+=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice E (#864) — CLI/MCP parity for segmented part operations: - CLI `qtmesh segment --explode-parts [--explode-distance ] [--solidify] -o scene.glb`: splits into per-part submeshes, then explodes each into its own scene node offset outward, and exports the multi-node scene via sceneExporter. Usage error (exit 2) without -o or with a bad distance. - MCP `explode_mesh_parts` ({entity_name?, distance?}) and `join_mesh_parts` ({entity_names?} — omit to join all mesh entities), both undoable via the same ExplodePartsCommand / JoinPartsCommand the GUI uses; registered heavy + mapped to the `segmentation` gamification cluster. - `segment_mesh` already returns `face_labels` unconditionally (the epic's `return_face_labels`). - Print-split/pegs descoped (removed earlier this epic — no reliable flat cut plane on organic joints; Meshy/Tripo ship no discrete pegs either). Slice F (#865) — docs/tests/polish: - New docs/PART_OPS.md: full workflow (split/explode/join/solidify), all three surfaces, and the documented limitations (body-centric labels, model-unit dimensions, static join, thin-shell solidify, no pegs/boolean). - README feature bullet + CLAUDE.md CLI/MCP/PartOps updates. - Breadcrumbs already present: mesh.parts.{segment_preview,split_segments, explode,join}. Tests: 3 new CLI explode coverage tests (usage errors + a multi-node-scene glb that parses the exported .glb JSON chunk and asserts >1 node with outward offsets — reimport-free so it doesn't hit glTF same-material merging or scene lights). Full PartOps suite 35 green. App builds clean; CLI explode verified end-to-end on Rumba (12 parts, offset nodes). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +- README.md | 1 + docs/PART_OPS.md | 99 ++++++++++++ src/CLIPipeline.cpp | 99 +++++++++++- ...LIPipeline_cmdsplitparts_coverage_test.cpp | 86 +++++++++++ src/MCPServer.cpp | 144 ++++++++++++++++++ src/MCPServer.h | 2 + 7 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 docs/PART_OPS.md diff --git a/CLAUDE.md b/CLAUDE.md index bbcf0f26..326e2573 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,8 @@ qtmesh segment model.fbx --no-model --up-axis y # force the deterministic geome qtmesh segment rigged.fbx --dump-training-data sample.json # mine EXACT rig-prior labels from a SKINNED mesh → training sample (#410; feed to export-meshseg-onnx.py --real-data) qtmesh segment model.fbx --write-labels labels.json # PartOps (#859/#861): dump per-vertex+per-face part labels (schema qtmesh-partops-labels-v1) without splitting qtmesh segment model.fbx --split-parts -o parts.fbx # PartOps (#859/#861): split the segmented mesh into one named submesh per part (head/torso/left_arm/…); boundary verts duplicated so parts are independent; preserves normals/uv/colour/tangent + skeleton & bone weights (skinned meshes stay riggable). FBX keeps the submesh boundaries; glTF coalesces same-material parts. Add --no-model for the offline geometric/rig-prior path +qtmesh segment model.fbx --split-parts --solidify -o parts.glb # PartOps (#863): also give each thin-shell part real WALL VOLUME (solidify) so a cut shows a solid cross-section instead of the hollow interior — for single-sided game-asset shells +qtmesh segment model.fbx --explode-parts -o scene.glb # PartOps (#864): split, then EXPLODE each part into its own scene node offset outward → a multi-node glTF scene. --explode-distance (default 0.15, × assembly diagonal); combine with --solidify qtmesh mocap talk.mp4 --face --mesh avatar.glb -o out.glb # performance capture (#869, needs -DENABLE_MOCAP): facial expressions -> ARKit-blendshape weight keyframes + head rotation (Head bone or node); models download on first use qtmesh mocap dance.mp4 --body --mesh rigged.fbx -o out.glb # full-body pose -> skeletal clip on the humanoid rig (root locked; --algo sam3dbody|pose-ik, sam3dbody falls back to pose-ik while its checkpoints are gated; --no-model forces the fallback) qtmesh mocap take.mp4 --face --body --mesh char.glb -o out.glb # both in one decode pass ("_Body" for the body clip) @@ -409,7 +411,7 @@ Video/webcam -> facial morph + head + skeletal body animation, built on the exis - **FaceRig** (`src/FaceRig/`, epic #889): auto-generate the 52 **ARKit blendshapes** on an unrigged humanoid **face** mesh so it can be driven by face performance capture (#869). **Deterministic geometry, no ML/ONNX, zero new dependencies** (the house rule, same as #402/#407). Pipeline: **`NonRigidICP`** (`NonRigidICP.{h,cpp}`, Amberg 2007 optimal-step, Slice C #892) fits the ICT-FaceKit template neutral onto the user's neutral → per-template-vertex correspondence; **`DeformationTransfer`** (`DeformationTransfer.{h,cpp}`, Sumner & Popović 2004, Slice D #893) builds each template triangle's deformation gradient `S = [e1' e2' n']·[e1 e2 n]⁻¹` (4th "normal" vertex trick, free per-triangle unknown, single-vertex gauge anchor) and solves one sparse least-squares per shape for the user-identity positions; **`FaceRigger`** (`FaceRigger.{h,cpp}`, Slice E #894) chains them and resamples template-topology deltas onto the real user verts via a dependency-free spatial-hash grid (built once for all 52 shapes) → per-user-vertex deltas per shape, named per `FaceCap::kBlendshapeNames`. The shared sparse solver is **`SparseSolve.{h,cpp}`** (CSR + CG on the normal equations — no Eigen). **`FaceRigAttach`** (`FaceRigAttach.{h,cpp}`) is the only Ogre-touching piece: `extractGeometry()` reads the entity's combined submesh geometry, and `attachShapes()` splits the deltas back per submesh handle and attaches them as `Ogre::Pose` + `VAT_POSE` morph targets via `AddMorphTargetCommand` (the exact MorphCommands pose-build, so face capture drives them with no new playback code). **Humanoid-only gate**: the NRICP fit residual is checked (`--max-residual`, default 8% of the mesh diagonal; non-finite / >5%-diverged fits treated as failed) — a non-face mesh fits poorly and is refused rather than emitting garbage. Surfaced via **CLI `qtmesh facerig -o [--max-shapes N] [--max-residual PCT] [--json]`** (`CLIPipeline::cmdFaceRig`), **MCP `add_arkit_blendshapes`** (`MCPServer::toolAddArkitBlendshapes`, `{max_shapes?, max_residual_pct?, output_path?}`, heavy), and the **Inspector Vertex Morph Animation → "✨ Add ARKit Blendshapes (AI)" button** (`FaceRigController` QML_SINGLETON — extracts + loads template on the main thread, runs the heavy fit on a WORKER thread, commits the attach as one undo macro on the main thread; gated on `hasMeshSelection`, shows "Downloading…/Fitting…" status). Template = **ICT-FaceKit** (MIT, `THIRD_PARTY_AI_MODELS.md`), packed by `scripts/export-arkit-template.py` → `facerig/arkit_template.bin`, hosted on the HF models repo (`scripts/upload-facerig-template.sh`), downloads on first use to `/ai_models/facerig/` (`ArkitTemplate::ensureModelBlocking`; overrides `QTMESH_FACERIG_MODEL_BASE_URL` / `QSettings ai/facerigModelBaseUrl`; offline guard `QTMESH_FACERIG_NO_DOWNLOAD`). Sentry breadcrumb `ai.assist.face_rig`. Verified end-to-end: real ICT template (26719v, 51 shapes) → decimated different-topology face (15755v), mean 0.008% / max 0.61% fit, 51 shapes attached, exported glb carries all 51 morph targets. **glTF export** carries the target geometry but not per-target `extras.targetNames` (a follow-up). Docs: `docs/FACE_RIG.md`; spike/contract: `docs/FACE_RIG_SPIKE.md`. - **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%. - **MeshSegmenter** (`src/MeshSegmenter.h/cpp`, issue #410 + categories #818 B2): AI mesh part segmentation — predicts a semantic part label per vertex + per face, with **category-specialised models** sharing ONE global `Part` vocabulary: body (head/torso/L+R arm/L+R leg — `meshseg.onnx`, the original 7-channel wire contract), vegetation (trunk/branch/foliage/root/flower — `meshseg_vegetation.onnx`), vehicle (vehicle_body/wheel/window/wing/rotor — `meshseg_vehicle.onnx`), building (wall/roof/window/door/chimney/foundation — `meshseg_building.onnx`; `window` is one global label shared with vehicle). `Options::category {Auto, Body, Vegetation, Vehicle, Building}`; **Auto runs a tiny point-cloud category classifier** (`meshseg_category.onnx`, PointNet max-pool 4-way, ~0.1 MB) via `resolveCategoryBlocking()` — classifier unavailable/offline → Body (the pre-B2 behaviour). Local model channels map to global parts via `categoryChannelMap()`; the geometric fallback is category-aware (vegetation foliage/trunk, vehicle wheel/body, building roof/wall up-bands); bone-proximity hints apply only to Body (they're body-part indices). One model per category (NOT one big softmax — label imbalance, coupled failures, full re-download per addition; decision + SmolVLM-as-dispatcher rejection recorded in `docs/MESH_SEGMENTATION_STRATEGY.md`; SmolVLM stays a follow-up GUI "identify/name" assist). CLI `--category`, MCP `category` arg; all models download from HF `segment/` under the same env/QSettings overrides. The **fourth ONNX consumer**; powers Edit-Mode "Select by part", per-part material assignment, and auto-rig priors. **Geometric fallback is first-class** (always compiled, Ogre-free): `segmentGeometric` does connected-component islands (`connectedComponents`, union-find) + an up-axis/lateral spatial heuristic (top→head, lower→legs, mid-sides→arms, centre→torso), overridable per-vertex by rig bone-proximity hints — used automatically when the build lacks ONNX, the model is missing/un-downloadable, or inference fails (`Result::usedModel`/`fallbackReason` report which ran). The **ONNX path** (`#ifdef ENABLE_ONNX`, PointNet++-style) normalises → deterministic point sample → `[1,N,3]` → per-point argmax over the part channels → scatters labels back to all vertices by nearest sampled point (runtime I/O-name discovery, channels-first/last handling, CoreML EP). `ensureModelBlocking()` downloads `meshseg.onnx` to `AppData/ai_models/segment/` (override `QTMESH_SEGMENT_MODEL_BASE_URL` / `QSettings ai/segmentModelBaseUrl`; offline guard `QTMESH_SEGMENT_NO_DOWNLOAD`; non-ONNX `#ifndef` guard) — the #408/#409 pattern. Pure-data helpers (`connectedComponents`, `facesFromVertexLabels`) are unit-tested without Ogre/GL. **Split-cleanup passes (#863, default ON via `Options::cleanupIslands`, applied after labelling on BOTH the model and geometric paths, then vertex labels reconciled via `vertexLabelsFromFaces`):** `smoothLabelBoundaries` straightens ragged part seams (the zigzag "fringe" teeth where the torso meets the legs) by flipping boundary faces that a strict majority of their edge-neighbours put in the other part (iterated, order-independent snapshot per pass); `cleanupLabelIslands` reabsorbs small DISCONNECTED face-islands near junctions (the floating fragments a split otherwise leaves) into the majority boundary-neighbour label — an island is a stray only if `< islandMinFaces` (32) AND `< islandMaxFraction` (2%) of its label AND not the label's largest island. Both operate on the FACE graph (shared-edge adjacency via `buildFaceAdjacency`), so they clean the exact thing PartOps split routes by. `levelLimbCut` (default ON via `Options::levelLimbCuts`, BODY category only) **mirror-symmetrises** the TWO LEG cuts across the sagittal plane so an explode is symmetric while preserving the model's natural DIAGONAL boundary (like the arms). It reflects the labelling across the leg region's lateral centre and makes each near-seam face agree with its mirror (union rule: a face is a limb if it OR its mirror is; torso only if both are), scoped to faces within a few edge-hops of the leg↔torso seam via a bounded flood. This fixes leg asymmetry WITHOUT a flat horizontal recut — a first horizontal-`h` version dragged the torso skirt into the legs and SWAPPED feet (reflection maps a foot to the opposite foot with limb labels swapped, so it keeps each foot with its own leg). Arms EXCLUDED (already symmetric; passed leg labels only). Verified on Hip Hop Dancing.obj: leg size ratio 0.84→1.00, feet stay on their own side, torso skirt not dragged (leg up_max = natural diagonal), arms untouched (2624/2624); rigged Rumba stayed balanced. `planarBoundaryRecut` (axis-snapped separating-plane recut + mirror-limb coupling for a fully knife-clean cut) exists but is **EXPERIMENTAL / OFF by default** (`Options::planarRecut=false`) — the band-reassign was too coarse and scrambled real characters; superseded in practice by the narrower `levelLimbCut`, kept for future refinement. Surfaced via **CLI `qtmesh segment [--json] [--no-model] [--up-axis x|y|z]`** (`CLIPipeline::cmdSegment` — text per-part counts or full label arrays), the **MCP `segment_mesh` tool** (`MCPServer::toolSegmentMesh`, args `{entity_name?, no_model?}`, heavy), and the **Edit Mode → "Select by Part (AI)" button** (`EditModeController::selectByPart()` → selects all faces matching the selected face's part, or the largest part if none selected; pushes via `selectFace` so the existing highlight refreshes). Sentry breadcrumb `ai.assist.segment`. **Model: ours (v2), trained on surface-sampled synthetic bodies (humanoid/chibi/quadruped/biped-tail plans) + mined CC0 Quaternius rigs** (rig bone-weight → part; ShapeNet-Part/PartNet are non-commercial and rejected) via `scripts/export-meshseg-onnx.py` (offline, not shipped) — the v2 loader canonicalises arbitrarily-oriented mined clouds from their own labels and geometrically fixes miner side errors; hosted on the HF models repo under `segment/` (see `THIRD_PARTY_AI_MODELS.md` + `docs/MESH_SEGMENTATION_STRATEGY.md` for the v1 failure analysis, accuracy numbers, and the multi-category roadmap). **Three-tier dispatch in `selectByPart`**: (1) **rig-prior** — if the mesh is SKINNED, label each vertex by the part of the bone it's most-weighted to (`AutoRig::rigPriorPartLabels` → `MeshSegmenter::partForBoneName`); EXACT and handles non-human anatomy (ears/snout→head, tail→torso, paws→leg) the coordinate model can't. Used when it resolves ≥70% of vertices. (2) **ONNX model** (UNrigged meshes). (3) **geometric fallback**. The ONNX path also applies `Options::upAxis` by remapping the sampled point cloud to the model's +Y-up training frame before inference (and in the nearest-point scatter), so X/Z-up meshes aren't mislabelled. **Continual-training miner** (the "train further as we gather data" loop): `qtmesh segment --dump-training-data out.json` runs the rig-prior path and writes the normalised point cloud + EXACT per-vertex labels (schema `qtmesh-meshseg-training-v1`) — every rigged asset becomes one free, exactly-labelled sample. `scripts/export-meshseg-onnx.py --real-data ` MIXES those mined JSONs (with yaw/tilt/jitter aug) into the synthetic set and retrains; gains land on the MODEL path used for unrigged meshes (rigged meshes already use the exact rig-prior path in-app). `AutoRig::rigPriorPartLabels` is the shared extractor for the GUI fast-path and the miner, so the in-app selection and mined ground truth are bit-identical. -- **PartOps** (`src/SubMeshOps.{h,cpp}`, `src/PartOpsMesh.{h,cpp}`, epic #859): turns `MeshSegmenter` output into real authoring ops. **`SubMeshOps`** is the Ogre-buffer-free, unit-tested core (operates on `EditableSubMesh` data so split/join/explode/peg math is headless-testable): `groupFacesByLabel` (face labels → stable `FaceGroup`s), `splitByFaceGroups` (#861 — one submesh per (part label, source material); duplicates boundary vertices so parts are independent; preserves normals/uv/colour/tangent/bone-assignments/n-gon faces; excluded groups dropped; optional connected-component sub-split; per-label name suffixes `head`, `head.1`…), `joinParts` (#862 — merge parts baking world transforms into positions/normals; same-material submeshes coalesce), `explodeOffsets` (#862), and `solidify` (#863 follow-up — gives a thin-shell part real wall volume; also the thing that makes a cut read as solid). *(The #863 3D-print alignment-peg sub-feature AND the `capOpenBoundaries` cut-face capper were both REMOVED. Pegs: real dowel/socket connectors on organic AI-segmented character joints proved unreliable — no safe flat cut plane through a hip seam (it also slices the belly), which is why Meshy/Tripo cut organically but ship no discrete pegs. Cap: closing the cut RING is geometrically watertight but a thin single-sided game-shell still LOOKS hollow at the cut (its own back-wall sits right behind the flat cap); a recessed-rim variant was tried and created artifacts, so cap was dropped. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, Manifold CSG, `capOpenBoundaries`, `SplitOptions::capParts`, the explode "Cap open boundaries" toggle, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone. Split + explode/join + `solidify` (opt-in, which seals thin shells) is the shipped scope.)* **`PartOpsMesh`** is the Ogre adapter: reads an entity into attribute-complete `EditableSubMesh`es (same submesh-then-local triangle order as `AutoRig::gatherGeometry`, so `faceLabels` map 1:1), runs the split, and builds a fresh `Ogre::Mesh` via `EditableMesh::createNewMesh(recomputeNormals=false)` — rebinding the source skeleton + recompiling bone assignments so SKINNED characters stay riggable, and naming each submesh (`Mesh::nameSubMesh`) with its part. Part names round-trip through FBX (FBXExporter emits `getSubMeshNameMap` names → Assimp `aiMesh::mName` → `MeshProcessor` `nameSubMesh`) and show in the Scene tree (`SceneTreeModel` prefers the registered submesh name over the positional index). **Surfaces**: CLI `qtmesh segment --split-parts -o out` / `--write-labels`; **GUI** Object-mode Inspector "Split into Parts (AI)" section (inspector-native controls (InspectorCheckBox + InspectorButton idiom + ThemedComboBox)) → `PartOpsController::splitSelectedIntoParts` → undoable `SplitMeshCommand`; **MCP** `split_mesh_by_segments` (same command). `SplitMeshCommand` swaps the whole mesh on the scene node — a submesh-count change can't go through the in-place `EditMeshTopologyCommand`/`resizeEntityBuffers` path; Ctrl+Z restores the fused mesh. It clears the SelectionSet's entity + sub-entity references BEFORE destroying the old entity (they only auto-clean on `sceneNodeDestroyed`, but the node survives a mesh swap — skipping this dangles the transform-gizmo / Scene-tree pointers and crashes), then reselects the node. Slice A (#860) segmentation-preview caching lives in `EditModeController` (`partGroups()`, select/hide/exclude/rename, cleared on edit-mode exit + topology change). **Slice C (#862) — explode/join scene nodes**: `PartOpsScene` (`src/PartOpsScene.{h,cpp}`) is the SCENE-level Ogre adapter above `PartOpsMesh` (which builds one mesh) — pure builders that compute the target scene state but never mutate the graph (the undo commands own node create/destroy so undo can replay). `explodeEntity(entity, distance, base)` splits every submesh of a fused mesh into its own single-submesh `Ogre::Mesh` (preserving attributes/material/skeleton+bone-assignments, part name via `getSubMeshNameMap`) and computes an outward `SubMeshOps::explodeOffsets` per part (from part-vs-assembly centroids × distance × assembly diagonal); `joinEntities(entities, base)` reads each entity's submeshes + its node's `_getFullTransform()` into `SubMeshOps::JoinPart`s (world transform baked into positions, inverse-transpose into normals/tangents) and merges via `joinParts` (same-material coalesce; skeletons NOT reconciled — join yields static geometry). **`ExplodePartsCommand`** (`src/commands/`): redo destroys the fused node and creates N sibling part nodes at `srcTransform + orient·(scale∘offset)` (offset applied in the source node's local frame), reselecting them; undo destroys the parts and recreates the fused node bound to the resident original mesh. **`JoinPartsCommand`**: redo captures each part's mesh + node TRS (for undo), destroys the part nodes, creates ONE fused node at the ORIGIN (positions already world-baked) reselecting it; undo destroys the fused node and recreates every part with its captured transform. Both use **create-then-destroy** ordering in BOTH redo and undo (new part/fused names never collide with the node being replaced, so they coexist momentarily) — the replacement is fully built + validated before the old node is destroyed, and a creation failure rolls back leaving the original intact, so the scene is never orphaned. Both clear the SelectionSet before destroying entities (SplitMeshCommand's dangling-sub-entity-ref rationale), **preserve the source node's parent** (parts/fused node are reparented back under the same group via `Manager::reparentNode` + explicit local-TRS restore) and **reject nodes with child nodes** (a subtree they don't serialise) with a clear error. `PartOpsMesh::readSubMeshes` prefers the entity's **effective per-SubEntity material** (`SubEntity::getMaterialName`) over the base SubMesh name so a Material-Mode override isn't lost on split/explode/join; `SubMeshOps::joinParts` reverses triangle winding + flips tangent handedness under a **mirror (negative-determinant) transform** so a negative-scaled part doesn't join back-facing. **GUI**: Object-mode Inspector "Explode / Join Parts" section (`partOpsExplodeJoinComponent` in `qml/PropertiesPanel.qml`) → `PartOpsController::explodeSelected(distance)` / `joinSelected()`, gated on new `canExplode` (one multi-submesh selection) / `canJoin` (2+ selected) props. Breadcrumbs `mesh.parts.explode` / `mesh.parts.join`. Explode/join CLI+MCP parity is Slice E (#864). Breadcrumbs `mesh.parts.segment_preview` / `mesh.parts.split_segments`. Body-centric labels; glTF coalesces same-material parts (FBX preserves them). Tests: `SubMeshOps_test.cpp` (incl. join rotation-bakes-normals), `SplitMeshCommand_test.cpp`, `ExplodePartsCommand_test.cpp` / `JoinPartsCommand_test.cpp` (no-Ogre error-branch), `PartOpsMesh_material_coverage_test.cpp` (GL-gated: readSubMeshes prefers the effective per-SubEntity material override), `CLIPipeline_cmdsplitparts_coverage_test.cpp` (rigged round-trip: multi-submesh + tris + skeleton + names + unit-length normals). **Slice D (#863) — Solidify (`SubMeshOps::solidify`, `SplitOptions::solidifyParts`)**: thin-shell game assets are single-sided surfaces with no wall thickness, so an exploded part exposes its hollow interior at the cut. `solidify` offsets an INNER copy of the surface inward by a thickness (auto ≈1.5% of the AABB diagonal) along area-weighted vertex normals, reverses its winding, and stitches a wall between every open boundary edge and its inner counterpart (wall loop `b→a→ai→bi` cancels both the outer `a→b` and the reverse-wound inner dangling edges → watertight). Turns each part into a closed slab AND seals it. Opt-in: GUI "Solidify thin shells" checkbox in the Split section → `SplitMeshCommand` solidify param → `SplitOptions::solidifyParts`; CLI `segment --split-parts --solidify`; MCP `split_mesh_by_segments {solidify:true}`. Verified on Hip Hop Dancing.obj: each part ~2× verts, 0 welded open edges. Solidify winding gotcha: inner shell is reverse-wound so the wall must cancel BOTH the outer boundary edge (needs `b→a`) and the inner dangling edge (needs `ai→bi`). *(Two other #863 sub-features were built and REMOVED — see the parenthetical at the top of this entry: (1) the 3D-print alignment PEGS (unreliable on organic joints), and (2) `capOpenBoundaries`/`capParts` cut-face capping + the explode "Cap open boundaries" toggle — capping a cut RING is watertight but a thin game-shell still looks hollow at the cut, and a recessed-rim attempt made artifacts, so cap was dropped in favour of solidify.)* Remaining epic slices: E remaining MCP tools (explode/join), F docs. +- **PartOps** (`src/SubMeshOps.{h,cpp}`, `src/PartOpsMesh.{h,cpp}`, epic #859): turns `MeshSegmenter` output into real authoring ops. **`SubMeshOps`** is the Ogre-buffer-free, unit-tested core (operates on `EditableSubMesh` data so split/join/explode/peg math is headless-testable): `groupFacesByLabel` (face labels → stable `FaceGroup`s), `splitByFaceGroups` (#861 — one submesh per (part label, source material); duplicates boundary vertices so parts are independent; preserves normals/uv/colour/tangent/bone-assignments/n-gon faces; excluded groups dropped; optional connected-component sub-split; per-label name suffixes `head`, `head.1`…), `joinParts` (#862 — merge parts baking world transforms into positions/normals; same-material submeshes coalesce), `explodeOffsets` (#862), and `solidify` (#863 follow-up — gives a thin-shell part real wall volume; also the thing that makes a cut read as solid). *(The #863 3D-print alignment-peg sub-feature AND the `capOpenBoundaries` cut-face capper were both REMOVED. Pegs: real dowel/socket connectors on organic AI-segmented character joints proved unreliable — no safe flat cut plane through a hip seam (it also slices the belly), which is why Meshy/Tripo cut organically but ship no discrete pegs. Cap: closing the cut RING is geometrically watertight but a thin single-sided game-shell still LOOKS hollow at the cut (its own back-wall sits right behind the flat cap); a recessed-rim variant was tried and created artifacts, so cap was dropped. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, Manifold CSG, `capOpenBoundaries`, `SplitOptions::capParts`, the explode "Cap open boundaries" toggle, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone. Split + explode/join + `solidify` (opt-in, which seals thin shells) is the shipped scope.)* **`PartOpsMesh`** is the Ogre adapter: reads an entity into attribute-complete `EditableSubMesh`es (same submesh-then-local triangle order as `AutoRig::gatherGeometry`, so `faceLabels` map 1:1), runs the split, and builds a fresh `Ogre::Mesh` via `EditableMesh::createNewMesh(recomputeNormals=false)` — rebinding the source skeleton + recompiling bone assignments so SKINNED characters stay riggable, and naming each submesh (`Mesh::nameSubMesh`) with its part. Part names round-trip through FBX (FBXExporter emits `getSubMeshNameMap` names → Assimp `aiMesh::mName` → `MeshProcessor` `nameSubMesh`) and show in the Scene tree (`SceneTreeModel` prefers the registered submesh name over the positional index). **Surfaces**: CLI `qtmesh segment --split-parts -o out` / `--write-labels`; **GUI** Object-mode Inspector "Split into Parts (AI)" section (inspector-native controls (InspectorCheckBox + InspectorButton idiom + ThemedComboBox)) → `PartOpsController::splitSelectedIntoParts` → undoable `SplitMeshCommand`; **MCP** `split_mesh_by_segments` (same command). `SplitMeshCommand` swaps the whole mesh on the scene node — a submesh-count change can't go through the in-place `EditMeshTopologyCommand`/`resizeEntityBuffers` path; Ctrl+Z restores the fused mesh. It clears the SelectionSet's entity + sub-entity references BEFORE destroying the old entity (they only auto-clean on `sceneNodeDestroyed`, but the node survives a mesh swap — skipping this dangles the transform-gizmo / Scene-tree pointers and crashes), then reselects the node. Slice A (#860) segmentation-preview caching lives in `EditModeController` (`partGroups()`, select/hide/exclude/rename, cleared on edit-mode exit + topology change). **Slice C (#862) — explode/join scene nodes**: `PartOpsScene` (`src/PartOpsScene.{h,cpp}`) is the SCENE-level Ogre adapter above `PartOpsMesh` (which builds one mesh) — pure builders that compute the target scene state but never mutate the graph (the undo commands own node create/destroy so undo can replay). `explodeEntity(entity, distance, base)` splits every submesh of a fused mesh into its own single-submesh `Ogre::Mesh` (preserving attributes/material/skeleton+bone-assignments, part name via `getSubMeshNameMap`) and computes an outward `SubMeshOps::explodeOffsets` per part (from part-vs-assembly centroids × distance × assembly diagonal); `joinEntities(entities, base)` reads each entity's submeshes + its node's `_getFullTransform()` into `SubMeshOps::JoinPart`s (world transform baked into positions, inverse-transpose into normals/tangents) and merges via `joinParts` (same-material coalesce; skeletons NOT reconciled — join yields static geometry). **`ExplodePartsCommand`** (`src/commands/`): redo destroys the fused node and creates N sibling part nodes at `srcTransform + orient·(scale∘offset)` (offset applied in the source node's local frame), reselecting them; undo destroys the parts and recreates the fused node bound to the resident original mesh. **`JoinPartsCommand`**: redo captures each part's mesh + node TRS (for undo), destroys the part nodes, creates ONE fused node at the ORIGIN (positions already world-baked) reselecting it; undo destroys the fused node and recreates every part with its captured transform. Both use **create-then-destroy** ordering in BOTH redo and undo (new part/fused names never collide with the node being replaced, so they coexist momentarily) — the replacement is fully built + validated before the old node is destroyed, and a creation failure rolls back leaving the original intact, so the scene is never orphaned. Both clear the SelectionSet before destroying entities (SplitMeshCommand's dangling-sub-entity-ref rationale), **preserve the source node's parent** (parts/fused node are reparented back under the same group via `Manager::reparentNode` + explicit local-TRS restore) and **reject nodes with child nodes** (a subtree they don't serialise) with a clear error. `PartOpsMesh::readSubMeshes` prefers the entity's **effective per-SubEntity material** (`SubEntity::getMaterialName`) over the base SubMesh name so a Material-Mode override isn't lost on split/explode/join; `SubMeshOps::joinParts` reverses triangle winding + flips tangent handedness under a **mirror (negative-determinant) transform** so a negative-scaled part doesn't join back-facing. **GUI**: Object-mode Inspector "Explode / Join Parts" section (`partOpsExplodeJoinComponent` in `qml/PropertiesPanel.qml`) → `PartOpsController::explodeSelected(distance)` / `joinSelected()`, gated on new `canExplode` (one multi-submesh selection) / `canJoin` (2+ selected) props. Breadcrumbs `mesh.parts.explode` / `mesh.parts.join`. **Slice E (#864) — CLI/MCP parity**: CLI `qtmesh segment --explode-parts [--explode-distance ] [--solidify] -o scene.glb` (`CLIPipeline::cmdSegment`: splits → `PartOpsScene::explodeEntity` → one scene node per part at its offset → `MeshImporterExporter::sceneExporter` multi-node glTF); MCP **`explode_mesh_parts`** (`{entity_name?, distance?}`, undoable via `ExplodePartsCommand`) and **`join_mesh_parts`** (`{entity_names?}` — omit to join all mesh entities; undoable via `JoinPartsCommand`), both registered heavy + mapped to the `segmentation` gamification cluster. `segment_mesh` already returns `face_labels` always (the epic's `return_face_labels`). Print-split/pegs were descoped (removed this epic). Breadcrumbs `mesh.parts.segment_preview` / `mesh.parts.split_segments`. Body-centric labels; glTF coalesces same-material parts (FBX preserves them). Tests: `SubMeshOps_test.cpp` (incl. join rotation-bakes-normals), `SplitMeshCommand_test.cpp`, `ExplodePartsCommand_test.cpp` / `JoinPartsCommand_test.cpp` (no-Ogre error-branch), `PartOpsMesh_material_coverage_test.cpp` (GL-gated: readSubMeshes prefers the effective per-SubEntity material override), `CLIPipeline_cmdsplitparts_coverage_test.cpp` (rigged round-trip: multi-submesh + tris + skeleton + names + unit-length normals). **Slice D (#863) — Solidify (`SubMeshOps::solidify`, `SplitOptions::solidifyParts`)**: thin-shell game assets are single-sided surfaces with no wall thickness, so an exploded part exposes its hollow interior at the cut. `solidify` offsets an INNER copy of the surface inward by a thickness (auto ≈1.5% of the AABB diagonal) along area-weighted vertex normals, reverses its winding, and stitches a wall between every open boundary edge and its inner counterpart (wall loop `b→a→ai→bi` cancels both the outer `a→b` and the reverse-wound inner dangling edges → watertight). Turns each part into a closed slab AND seals it. Opt-in: GUI "Solidify thin shells" checkbox in the Split section → `SplitMeshCommand` solidify param → `SplitOptions::solidifyParts`; CLI `segment --split-parts --solidify`; MCP `split_mesh_by_segments {solidify:true}`. Verified on Hip Hop Dancing.obj: each part ~2× verts, 0 welded open edges. Solidify winding gotcha: inner shell is reverse-wound so the wall must cancel BOTH the outer boundary edge (needs `b→a`) and the inner dangling edge (needs `ai→bi`). *(Two other #863 sub-features were built and REMOVED — see the parenthetical at the top of this entry: (1) the 3D-print alignment PEGS (unreliable on organic joints), and (2) `capOpenBoundaries`/`capParts` cut-face capping + the explode "Cap open boundaries" toggle — capping a cut RING is watertight but a thin game-shell still looks hollow at the cut, and a recessed-rim attempt made artifacts, so cap was dropped in favour of solidify.)* **Slice E (#864, done)**: CLI `--explode-parts` + MCP `explode_mesh_parts`/`join_mesh_parts` (see the explode/join clause above). **Slice F (#865)**: docs (this entry + README + `docs/PART_OPS.md`), UI tooltips, breadcrumbs (all `mesh.parts.*` present), headless-safe command tests. - **Image-to-3D (TripoSR)** (`src/ImageTo3D/`, epic #764): single-image → 3D mesh generation via **TripoSR** (Tripo AI + Stability AI, **MIT code AND MIT weights**, HF `stabilityai/TripoSR`). The **fifth ONNX consumer** (after #404/#408/#409/#410); all files live in the `src/ImageTo3D/` feature folder. MIT code+weights is the deciding factor for redistribution (Homebrew/Snap/WinGet/Docker) — the bar UniRig #408 cleared and non-commercial SF3D failed. **`MeshGenPredictor`** (Ogre-free + unit-tested) runs two exported ONNX graphs — encoder `image[1,3,512,512]→scene_codes[1,3,40,64,64]` (triplane) and per-point decoder `scene_codes+points[1,P,3]→density[1,P,1],color[1,P,3]` — GENERATING query points per chunk (not the whole `res³` grid up front — that would OOM at 512) and extracting the surface with **`MarchingCubes`** (native Lorensen impl, public-domain tables, zero deps; TripoSR's `torchmcubes` is torch/GPU-only). Surface = MC on `density − threshold` at iso 0 (threshold 25.0, radius 0.87); our MC is inside-positive so `extract()` emits `v0,v2,v1` (flipped winding) to keep faces OUTWARD (else the mesh renders inside-out). **Model size tiers** (`MeshGenPredictor::Quality {Fp32,Int8}` → `triposr_encoder{,_int8}.onnx`): fp32 ~1.68 GB (best), int8 ~430 MB (slight quality loss); user-selectable, downloads on demand. (fp16 was dropped — TripoSR's attention has a hardcoded Cast-to-float32 the ONNX fp16 converters can't rewrite; int8 is smaller anyway.) **`MeshGenBuilder`** (the ONLY Ogre-touching piece) turns the arrays into an `Ogre::Mesh` (POSITION + accumulated per-vertex NORMAL + optional DIFFUSE `VET_COLOUR` with a lit vertex-color material; 16-/32-bit index by vertex count; validates index data first), **bakes -90°X + +90°Y** into positions+normals so the model stands upright and faces forward, uses a UNIQUE per-call node/mesh name, and returns the SceneNode for export. **Background removal:** `BackgroundRemover` (6th ONNX consumer) runs **U²-Net** (Apache-2.0, rembg's model) to isolate the subject: `[1,3,320,320]`→`[1,1,320,320]` saliency, then composites over **gray 128** (not white — white → a reconstructed wall) and crops/re-pads to the subject at 0.85 foreground ratio (TripoSR's `resize_foreground`). Model `ai_models/rembg/u2net.onnx` (`QTMESH_REMBG_MODEL_BASE_URL`/`ai/rembgModelBaseUrl`; guard `QTMESH_REMBG_NO_DOWNLOAD`); falls back to the raw image if unavailable. Everything `ENABLE_ONNX`-guarded; **no fallback** (generative), so a non-ONNX build / missing model returns a clear error (never crashes). Models under `ai_models/triposr/` download on first use (`ensureModelBlocking(q)`; `QTMESH_TRIPOSR_MODEL_BASE_URL`/`ai/triposrModelBaseUrl`; guard `QTMESH_TRIPOSR_NO_DOWNLOAD`), OR can be **pre-downloaded from the AI Settings modal's Download tab** (tier picker + progress bar). **Export is `scripts/export-triposr-onnx.py`** (offline, not shipped; `transformers==4.35.0`, `torchmcubes` stub, frozen ViT pos-encoding; emits the int8 variant unless `--no-quant` — see `docs/IMAGE_TO_3D_SPIKE_764.md`). Surfaced via **CLI `qtmesh generate3d [-o out.glb] [--resolution 16..1024] [--no-color] [--remove-bg] [--quality fp32|int8]`** (`CLIPipeline::cmdGenerate3d`), **MCP `generate_mesh_from_image`** (`MCPServer::toolGenerateMeshFromImage`, args `{image_path, output?, resolution?, vertex_color?, remove_bg?, quality?}`, heavy, ONNX-guarded schema), and the **Object Mode Tools → "AI: Image → 3D" inspector section** (`qml/PropertiesPanel.qml` → **`MeshGenController`**, a QML_SINGLETON that runs the whole pipeline on a WORKER THREAD — UI stays responsive — with a select-image→preview→generate flow, resolution + model-tier dropdowns, progress bar, and cancel; mesh construction is marshalled back to the main thread). Sentry breadcrumb `ai.assist.image_to_3d`. Verified end-to-end on macOS. **Models are HOSTED** on the `fernandotonon/QtMeshEditor-models` HF repo (`triposr/triposr_encoder.onnx` + `triposr_encoder_int8.onnx` + `triposr_decoder.onnx`, `rembg/u2net.onnx`) via `scripts/upload-triposr-models.sh` — first use downloads them; if ever absent, every surface reports a clean "not yet hosted" message (no crash). Design/spike note: `docs/IMAGE_TO_3D_SPIKE_764.md`; slices A #765 (spike) → B #766 predictor → C #767 mesh build → D #768 surfaces → E #769 tiers/pre-download/hosting/docs (all in PR #785). **Quality pass (post-#785, ON by default)**: after marching cubes the predictor runs (a) **`MeshRefine::taubinSmooth`** — Taubin λ|μ smoothing (volume-preserving, kills the res³-grid stair-stepping), (b) **`MeshRefine::isoProjectStep`** — one Newton step per vertex back onto the decoder's true iso-surface using forward-difference gradients from 4 extra decoder probes/vertex (recovers grid-quantized detail; both pure-data + unit-tested in `MeshRefine_test.cpp`), and (c) **`MeshGenBaker`** — xatlas auto-unwrap + UV-space triangle rasterization + per-texel decoder colour queries + chart-border dilation, producing UV0 + a real diffuse TEXTURE (default 1024²) instead of per-vertex colour — colour sharpness then scales with texture size, not vertex density (pure-data behind a `ColorSampler` callback; `MeshGenBaker_test.cpp`). `MeshGenBuilder` gained the textured path: saves the baked PNG (AppData/generated_textures/ or the export dir when given), registers the dir as a resource location, and binds a lit material with a named `diffuse_map` TUS. Bake failure falls back to vertex colours with `Result::warning` set (never fails the generation). **PBR stage (d, ON by default)**: `MeshGenBuilder::BuildOptions::generatePbrMaps` chains **#404 PBR map synthesis** onto the baked diffuse — normal + roughness PNGs written next to it (height skipped, no consumer) and bound into the material via the same recipe as the Material Editor's "Generate PBR maps from diffuse" button (canonical `normal_map`/`roughness` TUS + `wirePbrSlotsForFFP` + `RTShaderHelper::applyNormalMap` — without applyNormalMap the bind is invisible in the viewport — + recompile). This is what turns the flat diffuse-only result into a polished, surface-detailed one; fails soft to diffuse-only when the PBRify models are unavailable. The exported material references all three maps (FBX embeds them; the PNGs land next to the export). **Every stage is user-selectable**: GUI checkboxes in the AI section (Remove background / Smooth / Refine / Bake texture / PBR maps / Upscale 2×) feed an options QVariantMap into `MeshGenController::generateSelected`; CLI `--no-smooth --no-refine --no-bake-texture --no-pbr --texture-size N --upscale-texture`; MCP `smooth/refine/bake_texture/generate_pbr/texture_size/upscale_texture`. The GUI runs the upscale on the WORKER thread (model pre-ensured on the main thread) and the PBR synthesis on the main thread inside buildSceneNode (small models, Material-Editor precedent). **TripoSG backend** (`src/ImageTo3D/TripoSGPredictor.{h,cpp}`, the SEVENTH ONNX consumer): `MeshGenPredictor::Options::backend {TripoSR|TripoSG}` dispatches to **TripoSG** (VAST-AI, SIGGRAPH 2025, **MIT code + MIT weights**, geometry ≈ commercial Tripo 2.0) — a 1.5B rectified-flow DiT over an SDF VAE, run as FOUR exported graphs (`scripts/export-triposg-onnx.py`, offline dev tool; measured contract in `docs/TRIPOSG_EXPORT_NOTES.md`): DINOv2-224 image encoder (mean/std baked in; CFG uncond = zeros) → **C++ Euler flow loop** over the DiT step graph (σᵢ = 1−i/N, timestep = 1000·σ, update `x += (σᵢ−σᵢ₊₁)·v` — sign is OPPOSITE of stock diffusers FlowMatchEuler; CFG as two B=1 calls, guidance 7.0, steps knob default 25) → VAE latent kv-cache graph (run ONCE per generation) → per-point field decoder (already inside-positive, iso 0, bounds ±1.005) → the same native MarchingCubes + smooth/reproject polish. Geometry-only (no colour decoder): bake/PBR/upscale stages are TripoSR-only; background removal for TripoSG composites over WHITE (its reference pipeline) vs TripoSR's gray-128. fp32 DiT ships as `.onnx`+`.onnx.data` (>2 GB external weights) with an int8 single-file tier mapped from `Quality::Int8`. Models under `ai_models/triposg/` download on first use (`QTMESH_TRIPOSG_MODEL_BASE_URL`/`ai/triposgModelBaseUrl`; guard `QTMESH_TRIPOSG_NO_DOWNLOAD`); clean "not hosted yet" error until the export is run + hosted. Surfaced via CLI `--backend triposr|triposg --flow-steps N`, MCP `backend`/`flow_steps` args, and the GUI Backend dropdown (the step list gains a "Denoise (flow steps)" row via `Stage::Denoise`). Roadmap/audit: `docs/IMAGE_TO_3D_QUALITY.md`. **TripoSG post-integration updates (supersede the "geometry-only / int8 tier / white-bg / disabled texture checkboxes" claims above):** (1) **int8 tier DROPPED** — even per-channel-quantized, the 1.5B DiT degrades to blobs over the 25-step CFG flow loop (live-verified), and dynamic-int8 MatMuls are no faster than fp32 on ARM; all surfaces force fp32 (CLI prints a note; the GUI Model picker collapses to "fp32 (only option for TripoSG)" and locks; the `quality` param now only selects the TripoSR tier used for the colour bake). (2) **Colour** — TripoSG has no colour decoder, so `MeshGenPredictor::colorizeWithTripoSR` bakes colour by (a) projecting the actual input PHOTO onto the visible front (depth-buffer-gated front-most-surface test; camera looks toward +Z so nearest = max z; soft depth-band crossfade to the field) and (b) filling occluded/back texels from **TripoSR's image-conditioned colour field** (the TripoSG mesh mapped into TripoSR's native frame + per-axis affine-fit onto its occupied bounds). The front is photo-accurate; the back is inferred/approximate. Falls soft to a shared neutral **lit clay material** (`MeshGen/NeutralClay`) on any failure. Texture/PBR/upscale stages + their GUI checkboxes are ENABLED for TripoSG (route through the colour bake). (3) **AI texture (GUI, `ENABLE_STABLE_DIFFUSION`)** — a "Generate texture (AI, front photo + generated back)" checkbox runs the existing **multi-view depth-ControlNet bake** (`MaterialEditorQML::generateMeshTextureMultiView`, `MultiViewTextureBaker`) after the mesh builds, with the input photo PINNED as the front view (img2img is disabled on Metal, so the photo is injected as a filled view rather than an init image) and back/sides SD-generated; needs a loaded SD model. (4) **Orientation** — TripoSG output is already +Y-up (`Result::bakeTripoSROrientation=false` skips the TripoSR -90°X/+90°Y bake); its decoder field is negated at the sample site (exported graph lands OUTSIDE-positive → inverted winding otherwise). (5) **Memory/speed** — decoder chunk hard-capped at 8192 pts (cross-attention to 2048 kv tokens; TripoSR's 262144 chunk OOM-killed at ~90 GB); ONNX sessions staged (opened/released per stage, ~1 GB peak vs the >4 GB sum); the ~48 MB point decoder can run on the CoreML GPU via `QTMESH_TRIPOSG_COREML_DECODER=1` (default CPU — per-call kv re-upload made GPU slower); `--guidance` knob (CLI/MCP). Next speed win: hierarchical extraction (coarse grid → refine near surface). SF3D (non-commercial) and Hunyuan3D (EU-excluded) rejected for the texture upgrade; MV-Adapter (VAST-AI, Apache-2.0) is the tracked multi-view candidate. - **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` / `uv_unwrap_selection`, and the **Material Mode → Mode Tools → "Auto UV Unwrap…" button** (`qml/UvUnwrapDialog.qml`, driven by `UvUnwrapController` singleton). Sentry breadcrumb category `mesh.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. - **UV Editor** (`src/UVEditorController.h/cpp`, issues #463–#465): dedicated UV editing mode (Material Mode toolbar → UV Editor). **UVEditorController** (QML_SINGLETON) owns the 2D UV viewport overlay, island selection, transform gizmos (translate/rotate/scale UVs), pin/sew/split, seam marking in Edit Mode, geometric projection (View/Box/Cylinder/Sphere/Reset), and partial xatlas unwrap of selected faces. Core math lives in `UVTransform`, `UvProject`, `UvSeamData`/`UvSeamOps`, and undo via `UVEditCommand` / `UvSeamCommands`. **Headless parity** (#465) is centralized in `UvPipeline` (`src/UvPipeline.h/cpp`): `analyzeEntity` (channel info + island count + AABB overlap upper bound), `projectEntity`, `parseSeamEdgeList`/`setSeamsOnEntity`, `unwrapEntity`, and `unwrapTriangles` (face-mask partial unwrap). CLI: `qtmesh uv --info`, `--project`, `--set-seams`, `--unwrap`. MCP: `uv_info`, `uv_project`, `uv_set_seams`, `uv_unwrap_selection` (+ existing `auto_uv_unwrap`). Sentry categories: `mesh.uv.transform`, `mesh.uv.pin`, `mesh.uv.sew`, `mesh.uv.split`, `mesh.uv.seam`, `mesh.uv.project`, `mesh.uv.unwrap`, `mesh.uv.unwrap_selected`, `mesh.uv.info`. Keyboard shortcuts (UV Editor active): `G` translate, `R` rotate, `S` scale, `P` pin toggle, projection buttons in toolbar; `Tab` exits back to Object mode. diff --git a/README.md b/README.md index 70ff37e9..03c749cc 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PART_OPS.md b/docs/PART_OPS.md new file mode 100644 index 00000000..ae069a09 --- /dev/null +++ b/docs/PART_OPS.md @@ -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`. diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 206e62ff..a653706b 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -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" @@ -10357,6 +10358,8 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) 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; @@ -10371,6 +10374,20 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) 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) { @@ -10433,7 +10450,8 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) "[--category auto|body|vegetation|vehicle|building] " "[--no-island-cleanup] " "[--dump-training-data ] [--write-labels ] " - "[--split-parts [--solidify] -o ]" << Qt::endl; + "[--split-parts [--solidify] -o ] " + "[--explode-parts [--explode-distance ] [--solidify] -o ]" << Qt::endl; return 2; } QFileInfo fi(inputPath); @@ -10685,6 +10703,85 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) return 0; // split path produces its own output; skip the label dump below } + // --- PartOps: split then EXPLODE into separate nodes (#864) ------------- + if (explodeParts) { + if (outputPath.isEmpty()) { + err() << "Error: --explode-parts requires -o ." << Qt::endl; + return 2; + } + // 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 the fused source node; create one node per part at its + // outward offset. Each entity is named after its node (Manager does + // this), which sceneExporter needs to discover it. + mgr->destroyAllAttachedMovableObjects(srcNode); + mgr->destroySceneNode(srcNode); + 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) { + 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(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; + } + if (jsonOutput) { QJsonObject root; root["mesh"] = fi.fileName(); diff --git a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp index 1931bc7e..d6090b25 100644 --- a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -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(static_cast(blob[off])) + | (static_cast(static_cast(blob[off + 1])) << 8) + | (static_cast(static_cast(blob[off + 2])) << 16) + | (static_cast(static_cast(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(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) { diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index f8969086..1b6b6509 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -143,6 +143,8 @@ #include "SubMeshOps.h" #include "PartOpsMesh.h" #include "commands/SplitMeshCommand.h" +#include "commands/ExplodePartsCommand.h" +#include "commands/JoinPartsCommand.h" #include "commands/TransformCommands.h" #ifdef Q_OS_WIN @@ -679,6 +681,8 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("pin_feet"), &MCPServer::toolPinFeet}, {QStringLiteral("segment_mesh"), &MCPServer::toolSegmentMesh}, {QStringLiteral("split_mesh_by_segments"), &MCPServer::toolSplitMeshBySegments}, + {QStringLiteral("explode_mesh_parts"), &MCPServer::toolExplodeMeshParts}, + {QStringLiteral("join_mesh_parts"), &MCPServer::toolJoinMeshParts}, {QStringLiteral("generate_mesh_from_image"), &MCPServer::toolGenerateMeshFromImage}, {QStringLiteral("save_scene"), &MCPServer::toolSaveScene}, {QStringLiteral("open_scene"), &MCPServer::toolOpenScene}, @@ -774,6 +778,8 @@ bool MCPServer::isHeavyTool(const QString &name) QStringLiteral("generate_motion"), QStringLiteral("segment_mesh"), QStringLiteral("split_mesh_by_segments"), + QStringLiteral("explode_mesh_parts"), + QStringLiteral("join_mesh_parts"), QStringLiteral("add_arkit_blendshapes"), QStringLiteral("generate_mesh_from_image"), QStringLiteral("save_scene"), @@ -850,6 +856,8 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) {QStringLiteral("merge_animations"), QStringLiteral("animation_blend")}, {QStringLiteral("segment_mesh"), QStringLiteral("ai_assist")}, {QStringLiteral("split_mesh_by_segments"), QStringLiteral("ai_assist")}, + {QStringLiteral("explode_mesh_parts"), QStringLiteral("segmentation")}, + {QStringLiteral("join_mesh_parts"), QStringLiteral("segmentation")}, {QStringLiteral("capture_face_from_video"), QStringLiteral("ai_assist")}, {QStringLiteral("capture_body_from_video"), QStringLiteral("ai_assist")}, {QStringLiteral("generate_mesh_from_image"), QStringLiteral("image_to_3d")}, @@ -4895,6 +4903,111 @@ QJsonObject MCPServer::toolSplitMeshBySegments(const QJsonObject &args) } } +QJsonObject MCPServer::toolExplodeMeshParts(const QJsonObject &args) +{ + // PartOps explode (#862/#864): split an already-multi-part entity into one + // scene node per part, offset outward — via the SAME undoable + // ExplodePartsCommand the GUI button uses. + try { + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return makeErrorResult("Error: Manager not available"); + + const QString entityName = args["entity_name"].toString(); + Ogre::Entity* entity = nullptr; + for (auto* ent : mgr->getEntities()) { + if (!ent || ent->getMovableType() != "Entity") continue; + if (entityName.isEmpty() + || QString::fromStdString(ent->getName()) == entityName) { entity = ent; break; } + } + if (!entity) + return makeErrorResult(entityName.isEmpty() + ? QString("Error: No mesh entity found") + : QString("Error: Entity '%1' not found").arg(entityName)); + if (!entity->getMesh() || entity->getMesh()->getNumSubMeshes() < 2) + return makeErrorResult("Error: mesh has a single part — split it into parts first"); + + double distance = args.value("distance").toDouble(0.15); + if (distance < 0.0) distance = 0.0; + + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.explode"), + QStringLiteral("MCP explode_mesh_parts")); + const std::string entName = entity->getName(); + auto* cmd = new ExplodePartsCommand(entName, static_cast(distance)); + UndoManager::getSingleton()->push(cmd); // runs redo() synchronously + if (!cmd->ok()) + return makeErrorResult(cmd->error().isEmpty() + ? QString("Error: explode failed") : ("Error: " + cmd->error())); + + QJsonObject o; + o["explodedParts"] = cmd->createdParts(); + o["distance"] = distance; + return makeSuccessResult( + QString::fromUtf8(QJsonDocument(o).toJson(QJsonDocument::Indented))); + } catch (Ogre::Exception& e) { + return makeErrorResult(QString("Error: Ogre exception — %1").arg(e.getFullDescription().c_str())); + } catch (std::exception& e) { + return makeErrorResult(QString("Error: %1").arg(e.what())); + } +} + +QJsonObject MCPServer::toolJoinMeshParts(const QJsonObject &args) +{ + // PartOps join (#862/#864): merge 2+ named part entities (world transforms + // baked in) into one fused static mesh — via the undoable JoinPartsCommand. + try { + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) return makeErrorResult("Error: Manager not available"); + + // Resolve the target entities: an explicit "entity_names" array, else + // every mesh entity in the scene. + std::vector names; + QString fusedBase; + const QJsonArray requested = args.value("entity_names").toArray(); + if (!requested.isEmpty()) { + for (const QJsonValue& v : requested) { + const QString want = v.toString(); + for (auto* ent : mgr->getEntities()) { + if (!ent || ent->getMovableType() != "Entity" || !ent->getMesh()) continue; + if (QString::fromStdString(ent->getName()) == want) { + names.push_back(ent->getName()); + if (fusedBase.isEmpty()) + fusedBase = want + QStringLiteral("_fused"); + break; + } + } + } + } else { + for (auto* ent : mgr->getEntities()) { + if (!ent || ent->getMovableType() != "Entity" || !ent->getMesh()) continue; + names.push_back(ent->getName()); + if (fusedBase.isEmpty()) + fusedBase = QString::fromStdString(ent->getName()) + QStringLiteral("_fused"); + } + } + if (names.size() < 2) + return makeErrorResult("Error: need two or more part entities to join"); + + const int partCount = static_cast(names.size()); + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.join"), + QStringLiteral("MCP join_mesh_parts")); + auto* cmd = new JoinPartsCommand(std::move(names), fusedBase); + UndoManager::getSingleton()->push(cmd); // runs redo() synchronously + if (!cmd->ok()) + return makeErrorResult(cmd->error().isEmpty() + ? QString("Error: join failed") : ("Error: " + cmd->error())); + + QJsonObject o; + o["joinedParts"] = partCount; + o["createdSubMeshes"] = cmd->createdSubMeshes(); + return makeSuccessResult( + QString::fromUtf8(QJsonDocument(o).toJson(QJsonDocument::Indented))); + } catch (Ogre::Exception& e) { + return makeErrorResult(QString("Error: Ogre exception — %1").arg(e.getFullDescription().c_str())); + } catch (std::exception& e) { + return makeErrorResult(QString("Error: %1").arg(e.what())); + } +} + QJsonObject MCPServer::toolSaveScene(const QJsonObject &args) { try { @@ -9192,6 +9305,37 @@ QJsonArray MCPServer::buildToolsList() ); } + // explode_mesh_parts (#862/#864) — split a multi-part mesh into separate nodes. + { + QJsonObject props; + props["entity_name"] = QJsonObject{{"type", "string"}, {"description", "Entity to explode (must already have >=2 part submeshes — split it first). Empty → the first mesh entity."}}; + props["distance"] = QJsonObject{{"type", "number"}, {"description", "Outward explode offset multiplier (× the assembly diagonal). Default 0.15; 0 = parts coincident."}}; + appendTool( + "explode_mesh_parts", + "PartOps explode (#862/#864): split every submesh of an already-multi-part " + "entity into its own scene node, offset outward from the assembly centre. " + "Preserves materials, and (for a skinned source) the skeleton + bone " + "weights. Undoable (same command as the GUI 'Explode Parts' button). " + "Returns the exploded part count.", + props + ); + } + + // join_mesh_parts (#862/#864) — merge separate part entities into one mesh. + { + QJsonObject props; + props["entity_names"] = QJsonObject{{"type", "array"}, {"items", QJsonObject{{"type", "string"}}}, {"description", "Names of the 2+ part entities to join (world transforms baked in). Omit → join ALL mesh entities in the scene."}}; + appendTool( + "join_mesh_parts", + "PartOps join (#862/#864): merge 2+ part entities into ONE fused mesh, " + "baking each part's world transform into its geometry. Same-material " + "submeshes coalesce. Yields STATIC geometry (skeletons are NOT reconciled " + "— a documented join limitation). Undoable (same command as the GUI 'Join " + "Parts' button). Returns the joined part count + created submesh count.", + props + ); + } + #ifdef ENABLE_ONNX // generate_mesh_from_image (#764) — only advertised when ONNX is compiled in. { diff --git a/src/MCPServer.h b/src/MCPServer.h index ed965875..47f9ed10 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -222,6 +222,8 @@ private slots: QJsonObject toolPinFeet(const QJsonObject &args); // #856 foot-contact pin QJsonObject toolSegmentMesh(const QJsonObject &args); QJsonObject toolSplitMeshBySegments(const QJsonObject &args); + QJsonObject toolExplodeMeshParts(const QJsonObject &args); // #862/#864 + QJsonObject toolJoinMeshParts(const QJsonObject &args); // #862/#864 QJsonObject toolGenerateMeshFromImage(const QJsonObject &args); // #764 image-to-3D QJsonObject toolSaveScene(const QJsonObject &args); QJsonObject toolOpenScene(const QJsonObject &args); From 91b4a796b6f67f7a92254767d6185e9a350abe91 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 2 Aug 2026 11:59:29 -0400 Subject: [PATCH 2/4] fix(ci): re-pin stb to a live upstream commit (old pin dropped) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI was failing on EVERY platform at the CMake configure step: `fatal: unable to read tree (31c1ad3745…)` while cloning nothings/stb — the pinned commit was force-pushed out of upstream, so the shallow clone couldn't resolve it. This breaks master too, not just this PR. Re-pin to the current upstream master head 2c980bb5…. stb_image.h's .hdr decode API is unchanged; HDR/HdrEquirectLoader.cpp (the only consumer) compiles and the app builds clean against the new pin. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d12ec9e9..3d42f8b7 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 old pin 31c1ad3745… was dropped from upstream + # (force-push/rebase), so `git clone` failed with "unable to read tree" on + # every CI platform. 2c980bb… is the current upstream master head. + GIT_TAG 2c980bb59875b0d32144a71867fbdebb2f77cd20 GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(stb) From 4a41dc6b3ec230ea8870d8efdf868ccacf1af3d4 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 2 Aug 2026 15:01:58 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(#864):=20address=20PR=20#938=20review?= =?UTF-8?q?=20=E2=80=94=20no=20doubled=20geometry,=20join=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI --explode-parts: destroy the ORIGINAL imported source node (not just the temp split node) before sceneExporter, which walks every entity-bearing node — otherwise the exported scene overlaid an intact un-exploded mesh on top of the parts, doubling geometry (P1). Verified: exploded Rumba glb now has 12 meshes (was 13). - CLI --explode-parts: move the -o requirement check up-front, before segmentation (which may download an ONNX model), matching --split-parts — so a missing -o fails fast (Major). - MCP join_mesh_parts: reject an entity_names list with an UNRESOLVED name (previously silently joined the valid subset) or a DUPLICATE name (previously duplicated geometry + broke undo). Validate all resolve + are unique (Major). - MCP explode_mesh_parts / join_mesh_parts: map to the catalogued `ai_assist` gamification cluster (like the sibling segment tools) instead of the non-existent `segmentation` key, so usage events aren't dropped (P2). 34 PartOps tests green; app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CLIPipeline.cpp | 23 ++++++++++++++++------- src/MCPServer.cpp | 34 +++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index a653706b..d7af12b4 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10460,6 +10460,12 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) err() << "Error: --split-parts requires -o ." << 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 ." << Qt::endl; + return 2; + } if (!initOgreHeadless()) return 1; SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.segment"), @@ -10705,10 +10711,7 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) // --- PartOps: split then EXPLODE into separate nodes (#864) ------------- if (explodeParts) { - if (outputPath.isEmpty()) { - err() << "Error: --explode-parts requires -o ." << Qt::endl; - return 2; - } + // (-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; @@ -10736,11 +10739,17 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) << (ex.error.isEmpty() ? QStringLiteral("unknown") : ex.error) << Qt::endl; return 1; } - // 3) Remove the fused source node; create one node per part at its - // outward offset. Each entity is named after its node (Manager does - // this), which sceneExporter needs to discover it. + // 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) { diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 1b6b6509..b770b511 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -127,6 +127,7 @@ #include #include #include +#include #include #include #include @@ -856,8 +857,8 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) {QStringLiteral("merge_animations"), QStringLiteral("animation_blend")}, {QStringLiteral("segment_mesh"), QStringLiteral("ai_assist")}, {QStringLiteral("split_mesh_by_segments"), QStringLiteral("ai_assist")}, - {QStringLiteral("explode_mesh_parts"), QStringLiteral("segmentation")}, - {QStringLiteral("join_mesh_parts"), QStringLiteral("segmentation")}, + {QStringLiteral("explode_mesh_parts"), QStringLiteral("ai_assist")}, + {QStringLiteral("join_mesh_parts"), QStringLiteral("ai_assist")}, {QStringLiteral("capture_face_from_video"), QStringLiteral("ai_assist")}, {QStringLiteral("capture_body_from_video"), QStringLiteral("ai_assist")}, {QStringLiteral("generate_mesh_from_image"), QStringLiteral("image_to_3d")}, @@ -4962,26 +4963,37 @@ QJsonObject MCPServer::toolJoinMeshParts(const QJsonObject &args) // every mesh entity in the scene. std::vector names; QString fusedBase; + std::set seen; // reject duplicates (CodeRabbit) + auto pushName = [&](const std::string& n) { + if (seen.insert(n).second) { + names.push_back(n); + if (fusedBase.isEmpty()) + fusedBase = QString::fromStdString(n) + QStringLiteral("_fused"); + } + }; const QJsonArray requested = args.value("entity_names").toArray(); if (!requested.isEmpty()) { + // Every requested name MUST resolve to a mesh entity — a typo would + // otherwise silently join a subset while reporting success. A repeated + // name would duplicate that entity's geometry AND break the undo + // (JoinPartsCommand can't recreate two same-named source nodes). for (const QJsonValue& v : requested) { const QString want = v.toString(); + Ogre::Entity* found = nullptr; for (auto* ent : mgr->getEntities()) { if (!ent || ent->getMovableType() != "Entity" || !ent->getMesh()) continue; - if (QString::fromStdString(ent->getName()) == want) { - names.push_back(ent->getName()); - if (fusedBase.isEmpty()) - fusedBase = want + QStringLiteral("_fused"); - break; - } + if (QString::fromStdString(ent->getName()) == want) { found = ent; break; } } + if (!found) + return makeErrorResult(QString("Error: entity '%1' not found").arg(want)); + if (seen.count(found->getName())) + return makeErrorResult(QString("Error: entity '%1' listed more than once").arg(want)); + pushName(found->getName()); } } else { for (auto* ent : mgr->getEntities()) { if (!ent || ent->getMovableType() != "Entity" || !ent->getMesh()) continue; - names.push_back(ent->getName()); - if (fusedBase.isEmpty()) - fusedBase = QString::fromStdString(ent->getName()) + QStringLiteral("_fused"); + pushName(ent->getName()); } } if (names.size() < 2) From a093b96539f6ae160b828f154e076bb2bd7ed1f9 Mon Sep 17 00:00:00 2001 From: Fernando Date: Sun, 2 Aug 2026 23:16:01 -0400 Subject: [PATCH 4/4] docs(ci): reword stb re-pin comment to only the verified fetch failure The previous comment asserted a force-push/rebase cause that isn't verifiable; state only what was observed (the shallow-clone "unable to read tree" failure in CI) and that the new pin clones cleanly (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d42f8b7..e19ff1fc 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -431,9 +431,9 @@ message(STATUS "xatlas enabled (auto UV unwrap)") FetchContent_Declare( stb GIT_REPOSITORY https://github.com/nothings/stb.git - # Re-pinned 2026-08: the old pin 31c1ad3745… was dropped from upstream - # (force-push/rebase), so `git clone` failed with "unable to read tree" on - # every CI platform. 2c980bb… is the current upstream master head. + # 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 )