From 752159b9910718a97f568d6ab8912abe87126666 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 27 Jul 2026 20:35:12 -0400 Subject: [PATCH 01/12] =?UTF-8?q?feat(#863):=20PartOps=20Slice=20D=20?= =?UTF-8?q?=E2=80=94=20print-split=20prep=20with=20alignment=20pegs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns a segmented split into a 3D-printable one: add matching cylindrical alignment pegs at every stable part boundary so the printed parts snap together. - SubMeshOps::preparePrintPegs (pure-data orchestrator): for every pair of part submeshes, estimateBoundaryPlane the shared seam; where stable, buildAlignmentPegs and merge the MALE peg into partA + female SOCKET into partB as extra connector_male/connector_socket geometry (so each part stays one printable object). Tiny/non-planar boundaries are skipped with a per-pair reason (never fails the op). - Peg size AUTO-FITS the boundary: pegRadius is an upper bound clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale with it). - PartOpsMesh::addPrintPegsToEntity: Ogre adapter (part names from the submesh name map, skeleton preserved). AddPrintPegsCommand: undoable swap-mesh command. - Surfaces: CLI `qtmesh segment --print-pegs -o out.fbx` (split→peg→export, JSON/text report + skip warnings); MCP `prepare_print_split`; GUI Object-mode "Explode / Join Parts → Prepare for 3D Print" button + peg-size slider. Breadcrumb mesh.parts.print_pegs. Verified end to end on Hip Hop Dancing.obj: split → 5 torso↔part boundaries pegged → FBX export carrying the connectors; visually confirmed the sized pegs via the MCP RTT screenshot (exploded, textured). 7 Slice-D tests pass (peg add / tiny-boundary reject / needs-two-parts / command error-branch); GUI loads clean. Closes #863. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- qml/PropertiesPanel.qml | 80 +++++++++++++++++ src/CLIPipeline.cpp | 48 +++++++++- src/CMakeLists.txt | 1 + src/MCPServer.cpp | 80 +++++++++++++++++ src/MCPServer.h | 1 + src/PartOpsController.cpp | 44 +++++++++ src/PartOpsController.h | 10 +++ src/PartOpsMesh.cpp | 57 ++++++++++++ src/PartOpsMesh.h | 24 +++++ src/SubMeshOps.cpp | 93 +++++++++++++++++++ src/SubMeshOps.h | 36 ++++++++ src/SubMeshOps_test.cpp | 72 +++++++++++++++ src/commands/AddPrintPegsCommand.cpp | 104 ++++++++++++++++++++++ src/commands/AddPrintPegsCommand.h | 64 +++++++++++++ src/commands/AddPrintPegsCommand_test.cpp | 48 ++++++++++ tests/CMakeLists.txt | 1 + 17 files changed, 762 insertions(+), 3 deletions(-) create mode 100644 src/commands/AddPrintPegsCommand.cpp create mode 100644 src/commands/AddPrintPegsCommand.h create mode 100644 src/commands/AddPrintPegsCommand_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 43f83b39..02032169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg/socket geometry). **`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). Remaining epic slices: D print-peg dialog, E remaining MCP tools (explode/join/print), 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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg/socket geometry). **`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) — print-split prep with alignment pegs**: `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam, and where stable builds matching cylindrical pegs via `buildAlignmentPegs` — the MALE peg merged into partA + the female SOCKET into partB as extra `connector_male`/`connector_socket` geometry, so each part stays one printable object. Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), F docs. - **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/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 46f53bb7..8372fadb 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -6644,6 +6644,82 @@ Rectangle { } } + // --- 3D print prep: add alignment pegs (#863) --- + Rectangle { + width: parent ? parent.width - 16 : 200 + height: 1 + color: PropertiesPanelController.borderColor + opacity: 0.5 + } + Text { + width: parent.width - 16 + wrapMode: Text.WordWrap + color: PropertiesPanelController.textColor + font.pixelSize: 11 + text: "Add cylindrical alignment pegs at every part boundary so " + + "the printed parts snap together. Undoable." + } + // Peg radius slider (fraction; the actual peg auto-fits the boundary). + property real pegRadius: 1.5 + property real pegClearance: 0.2 + Row { + spacing: 6 + Text { + text: "Peg size:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + id: pegRadiusSlider + width: 110 + from: 0.2; to: 5.0; stepSize: 0.1 + value: partOpsEjContent.pegRadius + onValueChanged: partOpsEjContent.pegRadius = value + } + Text { + text: partOpsEjContent.pegRadius.toFixed(1) + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + } + Rectangle { + id: partOpsPrintBtn + property bool clickEnabled: PartOpsController.canExplode + width: Math.min(parent ? parent.width - 16 : 200, + partOpsPrintBtnLabel.implicitWidth + 20) + height: 26 + radius: 3 + opacity: clickEnabled ? 1.0 : 0.45 + color: partOpsPrintBtnMa.containsMouse && clickEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: partOpsPrintBtnLabel + anchors.centerIn: parent + text: "Prepare for 3D Print" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: partOpsPrintBtnMa + anchors.fill: parent + hoverEnabled: true + enabled: partOpsPrintBtn.clickEnabled + cursorShape: partOpsPrintBtn.clickEnabled + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + partOpsEjFeedback.color = PropertiesPanelController.textColor + partOpsEjFeedback.text = "Adding pegs…" + PartOpsController.preparePrintSplit( + partOpsEjContent.pegClearance, partOpsEjContent.pegRadius, 4.0, 3) + } + } + } + Text { id: partOpsEjFeedback width: parent.width - 16 @@ -6663,6 +6739,10 @@ Rectangle { partOpsEjFeedback.color = isError ? "#e06060" : "#60c060" partOpsEjFeedback.text = status } + function onPrintPrepFinished(status, isError) { + partOpsEjFeedback.color = isError ? "#e06060" : "#60c060" + partOpsEjFeedback.text = status + } function onSelectionChanged() { partOpsEjFeedback.text = "" } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index d04755ea..598aa158 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10357,6 +10357,7 @@ 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 printPegs = false; // PartOps #863: add alignment pegs after --split-parts bool jsonOutput = false; bool noModel = false; bool noIslandCleanup = false; // #863: raw labels, skip the split-cleanup pass @@ -10370,6 +10371,7 @@ 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 == "--print-pegs") { splitParts = true; printPegs = true; continue; } if (arg == "--write-labels") { if (i + 1 >= argc) { err() << "Error: --write-labels requires an output path." << Qt::endl; @@ -10431,7 +10433,7 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) "[--category auto|body|vegetation|vehicle|building] " "[--no-island-cleanup] " "[--dump-training-data ] [--write-labels ] " - "[--split-parts -o ]" << Qt::endl; + "[--split-parts | --print-pegs -o ]" << Qt::endl; return 2; } QFileInfo fi(inputPath); @@ -10646,10 +10648,42 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) } auto* mgr = Manager::getSingletonPtr(); Ogre::SceneNode* node = mgr ? mgr->addSceneNode("PartOpsSplit") : nullptr; - if (!node || !mgr->createEntity(node, so.mesh)) { + Ogre::Entity* splitEnt = (node && mgr) ? mgr->createEntity(node, so.mesh) : nullptr; + if (!splitEnt) { err() << "Error: could not build scene node for split mesh." << Qt::endl; return 1; } + + // #863: optionally add 3D-print alignment pegs at every stable part + // boundary, then export the pegged mesh instead. + int peggedBoundaries = 0, totalPegs = 0; + QStringList pegWarnings; + if (printPegs) { + SubMeshOps::PegOptions popts; // issue defaults (clearance .20, r 1.5, …) + PartOpsMesh::PrintPrepOutcome po = PartOpsMesh::addPrintPegsToEntity( + splitEnt, popts, fi.completeBaseName().toStdString() + "_pegged"); + if (!po.ok) { + err() << "Error: print-peg prep failed — " + << (po.error.isEmpty() ? QStringLiteral("unknown") : po.error) << Qt::endl; + return 1; + } + for (const QString& w : po.warnings) pegWarnings << w; + peggedBoundaries = po.peggedBoundaries; + totalPegs = po.totalPegs; + if (peggedBoundaries > 0) { + // Swap the pegged mesh onto the node for export. + node->detachObject(splitEnt); + mgr->getSceneMgr()->destroyEntity(splitEnt); + splitEnt = mgr->createEntity(node, po.mesh); + if (!splitEnt) { + err() << "Error: could not build node for pegged mesh." << Qt::endl; + return 1; + } + } else { + err() << "Warning: no stable part boundary — exporting without pegs." << Qt::endl; + } + } + const QString fmt = formatForExtension(outputPath); if (MeshImporterExporter::exporter( node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { @@ -10669,6 +10703,13 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) QJsonArray pn; for (const QString& n : so.partNames) pn.append(n); root["partNames"] = pn; + if (printPegs) { + root["peggedBoundaries"] = peggedBoundaries; + root["totalPegs"] = totalPegs; + QJsonArray warn; + for (const QString& w : pegWarnings) warn.append(w); + root["pegWarnings"] = warn; + } cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)) + "\n"); } else { cliWrite(QString("Split %1 into %2 part submeshes → %3\n") @@ -10676,6 +10717,9 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) .arg(QFileInfo(outputPath).fileName())); for (const QString& n : so.partNames) cliWrite(QString(" %1\n").arg(n)); + if (printPegs) + cliWrite(QString("Added %1 alignment pegs across %2 part boundaries.\n") + .arg(totalPegs).arg(peggedBoundaries)); } return 0; // split path produces its own output; skip the label dump below } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4fd1b51c..b8cc5362 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -95,6 +95,7 @@ commands/SkeletonResolver.cpp commands/ComputeSkinWeightsCommand.cpp commands/AutoRigCommand.cpp commands/SplitMeshCommand.cpp +commands/AddPrintPegsCommand.cpp commands/ExplodePartsCommand.cpp commands/JoinPartsCommand.cpp commands/SkeletonBoneCommands.cpp diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 34598a15..a52f1cda 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -143,6 +143,7 @@ #include "SubMeshOps.h" #include "PartOpsMesh.h" #include "commands/SplitMeshCommand.h" +#include "commands/AddPrintPegsCommand.h" #include "commands/TransformCommands.h" #ifdef Q_OS_WIN @@ -679,6 +680,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("pin_feet"), &MCPServer::toolPinFeet}, {QStringLiteral("segment_mesh"), &MCPServer::toolSegmentMesh}, {QStringLiteral("split_mesh_by_segments"), &MCPServer::toolSplitMeshBySegments}, + {QStringLiteral("prepare_print_split"), &MCPServer::toolPreparePrintSplit}, {QStringLiteral("generate_mesh_from_image"), &MCPServer::toolGenerateMeshFromImage}, {QStringLiteral("save_scene"), &MCPServer::toolSaveScene}, {QStringLiteral("open_scene"), &MCPServer::toolOpenScene}, @@ -774,6 +776,7 @@ bool MCPServer::isHeavyTool(const QString &name) QStringLiteral("generate_motion"), QStringLiteral("segment_mesh"), QStringLiteral("split_mesh_by_segments"), + QStringLiteral("prepare_print_split"), QStringLiteral("add_arkit_blendshapes"), QStringLiteral("generate_mesh_from_image"), QStringLiteral("save_scene"), @@ -850,6 +853,7 @@ 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("prepare_print_split"), 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")}, @@ -4894,6 +4898,60 @@ QJsonObject MCPServer::toolSplitMeshBySegments(const QJsonObject &args) } } +QJsonObject MCPServer::toolPreparePrintSplit(const QJsonObject &args) +{ + // PartOps print-prep (#859/#863): add alignment pegs to an already-split + // entity, via the SAME undoable AddPrintPegsCommand 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: entity has a single part — split it into parts first"); + + SubMeshOps::PegOptions opts; + if (args.contains("clearance")) opts.clearance = static_cast(args["clearance"].toDouble()); + if (args.contains("peg_radius")) opts.pegRadius = static_cast(args["peg_radius"].toDouble()); + if (args.contains("peg_depth")) opts.pegDepth = static_cast(args["peg_depth"].toDouble()); + if (args.contains("max_pegs_per_boundary")) opts.maxPegsPerBoundary = args["max_pegs_per_boundary"].toInt(); + + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.print_pegs"), + QStringLiteral("MCP prepare_print_split")); + + const QString entityNameOut = QString::fromStdString(entity->getName()); + auto* cmd = new AddPrintPegsCommand(entity->getName(), opts); + UndoManager::getSingleton()->push(cmd); // runs redo() synchronously + if (!cmd->ok()) + return makeErrorResult(cmd->error().isEmpty() + ? QString("Error: print prep failed") : ("Error: " + cmd->error())); + + QJsonObject o; + o["entity"] = entityNameOut; + o["peggedBoundaries"] = cmd->peggedBoundaries(); + o["totalPegs"] = cmd->totalPegs(); + QJsonArray warn; + for (const QString& w : cmd->warnings()) warn.append(w); + o["warnings"] = warn; + 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 { @@ -9190,6 +9248,28 @@ QJsonArray MCPServer::buildToolsList() ); } + // prepare_print_split (#859/#863): add 3D-print alignment pegs. + { + QJsonObject props; + props["entity_name"] = QJsonObject{{"type", "string"}, {"description", "Already-SPLIT entity to prep (>= 2 submeshes). Empty → the first mesh entity."}}; + props["clearance"] = QJsonObject{{"type", "number"}, {"description", "Socket radius = peg radius + clearance (model units). Default 0.20."}}; + props["peg_radius"] = QJsonObject{{"type", "number"}, {"description", "Male peg radius (model units). Default 1.50."}}; + props["peg_depth"] = QJsonObject{{"type", "number"}, {"description", "How far the peg protrudes / socket sinks. Default 4.00."}}; + props["max_pegs_per_boundary"] = QJsonObject{{"type", "integer"}, {"description", "Max pegs per part boundary. Default 3."}}; + appendTool( + "prepare_print_split", + "PartOps print-prep (#859/#863): add matching cylindrical alignment pegs " + "at every STABLE part boundary of an already-split mesh so the parts snap " + "together for 3D printing. The male peg is merged into one part and the " + "female socket into the other (as connector_male/connector_socket " + "geometry), so each part stays one printable object. Tiny/non-planar " + "boundaries are skipped with a warning (never fails). Undoable (same " + "command as the GUI 'Prepare Split for 3D Print' button). Returns the " + "pegged-boundary count, total pegs, and per-boundary skip warnings.", + 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..ada16554 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -222,6 +222,7 @@ private slots: QJsonObject toolPinFeet(const QJsonObject &args); // #856 foot-contact pin QJsonObject toolSegmentMesh(const QJsonObject &args); QJsonObject toolSplitMeshBySegments(const QJsonObject &args); + QJsonObject toolPreparePrintSplit(const QJsonObject &args); QJsonObject toolGenerateMeshFromImage(const QJsonObject &args); // #764 image-to-3D QJsonObject toolSaveScene(const QJsonObject &args); QJsonObject toolOpenScene(const QJsonObject &args); diff --git a/src/PartOpsController.cpp b/src/PartOpsController.cpp index 82776ae9..c4624441 100644 --- a/src/PartOpsController.cpp +++ b/src/PartOpsController.cpp @@ -6,6 +6,7 @@ #include "commands/SplitMeshCommand.h" #include "commands/ExplodePartsCommand.h" #include "commands/JoinPartsCommand.h" +#include "commands/AddPrintPegsCommand.h" #include #include @@ -179,3 +180,46 @@ void PartOpsController::joinSelected() emit joinFinished(tr("Joined %1 parts into one mesh (%2 submeshes).") .arg(partCount).arg(cmd->createdSubMeshes()), false); } + +void PartOpsController::preparePrintSplit(double clearance, double pegRadius, + double pegDepth, int maxPegsPerBoundary) +{ + const auto* sel = SelectionSet::getSingleton(); + if (!sel) { + emit printPrepFinished(tr("No selection."), true); + return; + } + const QList entities = sel->getResolvedEntities(); + if (entities.size() != 1 || !entities.first() || !entities.first()->getMesh()) { + emit printPrepFinished(tr("Select a single split mesh."), true); + return; + } + if (entities.first()->getMesh()->getNumSubMeshes() < 2) { + emit printPrepFinished(tr("Mesh has a single part — split it into parts first."), true); + return; + } + + SubMeshOps::PegOptions opts; + opts.clearance = static_cast(clearance); + opts.pegRadius = static_cast(pegRadius); + opts.pegDepth = static_cast(pegDepth); + opts.maxPegsPerBoundary = maxPegsPerBoundary; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("prepare_print_split")); + const std::string entName = entities.first()->getName(); + auto* cmd = new AddPrintPegsCommand(entName, opts); + UndoManager::getSingleton()->push(cmd); + + if (!cmd->ok()) { + emit printPrepFinished(cmd->error().isEmpty() ? tr("Print prep failed.") : cmd->error(), true); + return; + } + if (cmd->peggedBoundaries() == 0) { + emit printPrepFinished( + tr("No stable part boundary found — no pegs added. Try adjusting the peg size."), true); + return; + } + emit printPrepFinished( + tr("Added %1 pegs across %2 boundaries.").arg(cmd->totalPegs()).arg(cmd->peggedBoundaries()), + false); +} diff --git a/src/PartOpsController.h b/src/PartOpsController.h index 83189595..a2d05b38 100644 --- a/src/PartOpsController.h +++ b/src/PartOpsController.h @@ -67,11 +67,21 @@ class PartOpsController : public QObject * joinFinished(status, isError). No-op (error) with fewer than 2 selected. */ Q_INVOKABLE void joinSelected(); + /** Prepare the selected split mesh for 3D printing by adding cylindrical + * alignment pegs at every stable part boundary (undoable, #863). Reuses the + * same `canExplode` gate (one multi-submesh mesh). Emits + * printPrepFinished(status, isError). */ + Q_INVOKABLE void preparePrintSplit(double clearance = 0.20, + double pegRadius = 1.50, + double pegDepth = 4.00, + int maxPegsPerBoundary = 3); + signals: void selectionChanged(); void splitFinished(const QString& status, bool isError); void explodeFinished(const QString& status, bool isError); void joinFinished(const QString& status, bool isError); + void printPrepFinished(const QString& status, bool isError); private: PartOpsController(); diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index cd0eb886..4c0739a0 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -146,3 +146,60 @@ PartOpsMesh::splitEntity(Ogre::Entity* entity, out.duplicatedBoundaryVertices = split.duplicatedBoundaryVertices; return out; } + +PartOpsMesh::PrintPrepOutcome +PartOpsMesh::addPrintPegsToEntity(Ogre::Entity* entity, const SubMeshOps::PegOptions& opts, + const std::string& baseName) +{ + PrintPrepOutcome out; + if (!entity || !entity->getMesh()) { + out.error = QStringLiteral("no entity"); + return out; + } + if (entity->getMesh()->getNumSubMeshes() < 2) { + out.error = QStringLiteral("mesh has a single part — split it into parts first"); + return out; + } + std::vector src; + if (!readSubMeshes(entity, src)) { + out.error = QStringLiteral("could not read mesh geometry from entity"); + return out; + } + + // Recover per-part names from the mesh's submesh name map (a prior split + // named them head/torso/…), else positional. Used for the connector naming + // + boundary report. + const auto& nameMap = entity->getMesh()->getSubMeshNameMap(); + std::vector names(src.size()); + for (const auto& kv : nameMap) + if (kv.second < names.size()) + names[kv.second] = QString::fromStdString(kv.first); + for (size_t i = 0; i < names.size(); ++i) + if (names[i].isEmpty()) + names[i] = QStringLiteral("part%1").arg(i); + + SubMeshOps::PrintPrepResult prep = SubMeshOps::preparePrintPegs(src, opts, names); + if (!prep.ok && prep.subMeshes.empty()) { + out.error = prep.error; + return out; + } + for (const auto& b : prep.boundaries) + if (!b.pegged) + out.warnings.push_back(QStringLiteral("%1↔%2: %3").arg(b.nameA, b.nameB, b.reason)); + + QString skelName; + if (entity->getMesh()->hasSkeleton()) + skelName = QString::fromStdString(entity->getMesh()->getSkeletonName()); + Ogre::MeshPtr mesh = buildMesh(prep.subMeshes, baseName, skelName, prep.partNames); + if (!mesh) { + out.error = QStringLiteral("failed to build pegged mesh"); + return out; + } + + out.ok = true; // the op ran; peggedBoundaries==0 means no safe boundary. + out.mesh = mesh; + out.partNames = std::move(prep.partNames); + out.peggedBoundaries = prep.peggedBoundaries; + out.totalPegs = prep.totalPegs; + return out; +} diff --git a/src/PartOpsMesh.h b/src/PartOpsMesh.h index a35b9870..8ecc5619 100644 --- a/src/PartOpsMesh.h +++ b/src/PartOpsMesh.h @@ -75,6 +75,30 @@ class PartOpsMesh const std::vector& groups, const SubMeshOps::SplitOptions& opts, const std::string& baseName); + + struct PrintPrepOutcome { + bool ok = false; + QString error; + Ogre::MeshPtr mesh; ///< the pegged mesh (parts + connectors). + std::vector partNames; ///< one per submesh (unchanged part names). + int peggedBoundaries = 0; + int totalPegs = 0; + std::vector warnings; ///< per-boundary skip reasons. + }; + + /** Prepare an already-SPLIT entity (one submesh per part) for 3D printing by + * adding alignment pegs (Slice D #863): read its submeshes + their part + * names, run `SubMeshOps::preparePrintPegs`, and build a new mesh whose + * parts each carry their male-peg / female-socket connector geometry. The + * part names round-trip (each submesh keeps its name); connector geometry is + * merged INTO the parts (not new submeshes), so the part count is unchanged + * and each part stays one printable object. Preserves the source skeleton + * (a skinned character's parts stay riggable). Does NOT touch the live + * entity — the caller exports the returned mesh or swaps it via an undo + * command. Fails (`ok=false`) on a single-submesh mesh (nothing to peg). */ + static PrintPrepOutcome addPrintPegsToEntity(Ogre::Entity* entity, + const SubMeshOps::PegOptions& opts, + const std::string& baseName); }; #endif // PARTOPSMESH_H diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index f0c5f911..fd5ea835 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -611,3 +611,96 @@ int SubMeshOps::buildAlignmentPegs(const BoundaryPlane& plane, const PegOptions& } return made; } + +namespace { +// Append `src`'s vertices + triangles onto `dst` (offsetting the indices by +// dst's current vertex count). Used to merge a peg/socket cylinder into a part. +void appendGeometry(EditableSubMesh& dst, const EditableSubMesh& src) +{ + const unsigned int base = static_cast(dst.vertices.size()); + dst.vertices.insert(dst.vertices.end(), src.vertices.begin(), src.vertices.end()); + for (const EditableTriangle& t : src.triangles) { + EditableTriangle nt; + nt.indices[0] = t.indices[0] + base; + nt.indices[1] = t.indices[1] + base; + nt.indices[2] = t.indices[2] + base; + dst.triangles.push_back(nt); + } +} +} // namespace + +SubMeshOps::PrintPrepResult +SubMeshOps::preparePrintPegs(const std::vector& subMeshes, + const PegOptions& opts, const std::vector& partNames) +{ + PrintPrepResult out; + if (subMeshes.size() < 2) { + out.error = QStringLiteral("need at least two parts to add alignment pegs"); + return out; + } + out.subMeshes = subMeshes; // start from the parts; merge pegs in below. + out.partNames = partNames; + out.partNames.resize(subMeshes.size()); + auto nameOf = [&](int i) -> QString { + return (i >= 0 && i < static_cast(out.partNames.size()) && !out.partNames[i].isEmpty()) + ? out.partNames[i] : QStringLiteral("part%1").arg(i); + }; + + // For every unordered pair of parts, estimate the shared boundary; where it + // is stable, build a male peg (→ partA) + socket (→ partB) and merge each + // into its part as extra geometry. `estimateBoundaryPlane` works on submesh + // VECTORS, so wrap each part in a one-element vector. + const int n = static_cast(subMeshes.size()); + for (int a = 0; a < n; ++a) { + for (int b = a + 1; b < n; ++b) { + PegBoundary rec; + rec.partA = a; rec.partB = b; + rec.nameA = nameOf(a); rec.nameB = nameOf(b); + + const BoundaryPlane plane = estimateBoundaryPlane({ subMeshes[a] }, { subMeshes[b] }); + if (!plane.stable) { + rec.reason = plane.reason.isEmpty() + ? QStringLiteral("no stable shared boundary") : plane.reason; + out.boundaries.push_back(rec); + continue; + } + + // Adapt the peg size to THIS boundary so it always fits, regardless + // of the model's unit scale (the issue's fixed radius=1.5 is 80% of a + // unit-normalised character's diagonal — a giant blob). A peg radius + // is capped at 35% of the boundary ring radius, and the socket + // clearance / peg depth scale down with it (keeping their ratios to + // the user's request). The user's values are treated as an UPPER + // bound — a big model with a big boundary keeps them as-is. + PegOptions boundaryOpts = opts; + const float maxPegR = 0.35f * plane.radius; + if (maxPegR > 1e-4f && boundaryOpts.pegRadius > maxPegR) { + const float scale = maxPegR / boundaryOpts.pegRadius; + boundaryOpts.pegRadius = maxPegR; + boundaryOpts.pegDepth *= scale; + boundaryOpts.clearance *= scale; + } + + EditableSubMesh male, socket; + const int made = buildAlignmentPegs(plane, boundaryOpts, male, socket); + if (made <= 0) { + rec.reason = QStringLiteral("boundary too small for a peg"); + out.boundaries.push_back(rec); + continue; + } + // Merge the male peg into partA and the socket into partB. + appendGeometry(out.subMeshes[a], male); + appendGeometry(out.subMeshes[b], socket); + rec.pegged = true; + rec.pegCount = made; + out.boundaries.push_back(rec); + ++out.peggedBoundaries; + out.totalPegs += made; + } + } + + out.ok = true; + if (out.peggedBoundaries == 0) + out.error = QStringLiteral("no stable boundary found — no pegs added"); + return out; +} diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h index 65e90da1..b7ffbcff 100644 --- a/src/SubMeshOps.h +++ b/src/SubMeshOps.h @@ -197,6 +197,42 @@ class SubMeshOps * number of pegs generated (0 when the plane is unstable). Pure geometry. */ static int buildAlignmentPegs(const BoundaryPlane& plane, const PegOptions& opts, EditableSubMesh& outMale, EditableSubMesh& outSocket); + + /** One boundary the print-prep pass considered. */ + struct PegBoundary { + int partA = -1; ///< index into the input submeshes. + int partB = -1; + QString nameA, nameB; ///< the parts' display names (for messages). + bool pegged = false; ///< true when pegs were placed. + int pegCount = 0; + QString reason; ///< why skipped (when !pegged). + }; + + struct PrintPrepResult { + bool ok = false; + QString error; + /** The new submesh layout: the input parts, each with its male peg OR + * socket merged in as extra geometry, plus any parts unchanged. */ + std::vector subMeshes; + std::vector partNames; ///< parallel to subMeshes. + std::vector boundaries; ///< every pair considered (diag). + int peggedBoundaries = 0; + int totalPegs = 0; + }; + + /** Prepare a split mesh for 3D printing (Slice D #863). For EVERY pair of + * input submeshes that share a STABLE planar boundary (the seam a split + * left — coincident verts across the pair, via `estimateBoundaryPlane`), + * generate matching cylindrical pegs: the MALE peg is merged into `partA` + * and the female SOCKET-cutter into `partB` (each as extra geometry with a + * `connector_male`/`connector_socket` material), so each part carries its + * own connector and stays one printable object. Tiny / non-planar + * boundaries are skipped with a per-pair `reason` (never fails the whole + * op). `partNames` (optional, parallel to `subMeshes`) is used for the + * boundary report + connector naming. Deterministic; pure-data. */ + static PrintPrepResult preparePrintPegs(const std::vector& subMeshes, + const PegOptions& opts, + const std::vector& partNames = {}); }; #endif // SUBMESHOPS_H diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index fd8577be..1f940973 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -430,3 +430,75 @@ TEST(SubMeshOpsTest, AlignmentPegsSkippedOnUnstablePlane) EXPECT_EQ(SubMeshOps::buildAlignmentPegs(plane, opts, male, socket), 0); EXPECT_TRUE(male.triangles.empty()); } + +// ---- preparePrintPegs (Slice D #863) -------------------------------------- + +namespace { +// Two parts sharing a stable planar seam at x=0 (16 coincident verts on a 4×4 +// grid so the boundary radius is comfortably > peg radius). A extends to -x, +// B to +x. Each part has one triangle so it's a valid submesh. +void twoPartsWithSeam(EditableSubMesh& a, EditableSubMesh& b) +{ + a = EditableSubMesh(); b = EditableSubMesh(); + a.materialName = "Body"; b.materialName = "Body"; + for (int y = 0; y < 4; ++y) + for (int z = 0; z < 4; ++z) { + a.vertices.push_back(vtx(0, float(y), float(z))); + b.vertices.push_back(vtx(0, float(y), float(z))); + } + a.vertices.push_back(vtx(-2, 1.5f, 1.5f)); + b.vertices.push_back(vtx(2, 1.5f, 1.5f)); + addTri(a, 0, 1, 2); + addTri(b, 0, 1, 2); +} +} // namespace + +TEST(SubMeshOpsTest, PreparePrintPegsAddsMaleAndSocket) +{ + EditableSubMesh a, b; + twoPartsWithSeam(a, b); + const size_t aVerts0 = a.vertices.size(), bVerts0 = b.vertices.size(); + + SubMeshOps::PegOptions opts; + opts.pegRadius = 0.4f; opts.pegDepth = 1.0f; opts.maxPegsPerBoundary = 3; + auto r = SubMeshOps::preparePrintPegs({a, b}, opts, {"torso", "left_leg"}); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + EXPECT_EQ(r.peggedBoundaries, 1); + EXPECT_GT(r.totalPegs, 0); + ASSERT_EQ(r.subMeshes.size(), 2u); + // Each part gained connector geometry (more verts than it started with). + EXPECT_GT(r.subMeshes[0].vertices.size(), aVerts0); + EXPECT_GT(r.subMeshes[1].vertices.size(), bVerts0); + // The boundary report is populated with both part names. + ASSERT_EQ(r.boundaries.size(), 1u); + EXPECT_TRUE(r.boundaries[0].pegged); + EXPECT_EQ(r.boundaries[0].nameA.toStdString(), "torso"); + EXPECT_EQ(r.boundaries[0].nameB.toStdString(), "left_leg"); +} + +TEST(SubMeshOpsTest, PreparePrintPegsRejectsTinyBoundary) +{ + // Two parts that do NOT share enough coincident verts (< 8) → no stable + // boundary → no pegs, but the op succeeds with a per-pair reason. + EditableSubMesh a, b; + a.materialName = "Body"; b.materialName = "Body"; + a.vertices = {vtx(0,0,0), vtx(0,1,0), vtx(-1,0,0)}; + b.vertices = {vtx(5,0,0), vtx(5,1,0), vtx(6,0,0)}; // far away, no shared seam + addTri(a,0,1,2); addTri(b,0,1,2); + + auto r = SubMeshOps::preparePrintPegs({a, b}, SubMeshOps::PegOptions{}); + EXPECT_TRUE(r.ok); // never fails the whole op + EXPECT_EQ(r.peggedBoundaries, 0); + ASSERT_EQ(r.boundaries.size(), 1u); + EXPECT_FALSE(r.boundaries[0].pegged); + EXPECT_FALSE(r.boundaries[0].reason.isEmpty()); + EXPECT_FALSE(r.error.isEmpty()); // "no stable boundary found" +} + +TEST(SubMeshOpsTest, PreparePrintPegsNeedsTwoParts) +{ + EditableSubMesh a; a.vertices = {vtx(0,0,0), vtx(1,0,0), vtx(0,1,0)}; addTri(a,0,1,2); + auto r = SubMeshOps::preparePrintPegs({a}, SubMeshOps::PegOptions{}); + EXPECT_FALSE(r.ok); + EXPECT_FALSE(r.error.isEmpty()); +} diff --git a/src/commands/AddPrintPegsCommand.cpp b/src/commands/AddPrintPegsCommand.cpp new file mode 100644 index 00000000..38ff8863 --- /dev/null +++ b/src/commands/AddPrintPegsCommand.cpp @@ -0,0 +1,104 @@ +#include "AddPrintPegsCommand.h" + +#include "Manager.h" +#include "PartOpsMesh.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include + +AddPrintPegsCommand::AddPrintPegsCommand(std::string entityName, + SubMeshOps::PegOptions opts, QUndoCommand* parent) + : QUndoCommand(parent) + , mEntityName(std::move(entityName)) + , mOpts(opts) +{ + setText(QStringLiteral("Add Print Alignment Pegs")); +} + +Ogre::Entity* AddPrintPegsCommand::resolveEntity() const +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr) + return nullptr; + for (Ogre::Entity* e : mgr->getEntities()) { + if (e && e->getMovableType() == "Entity" && e->getName() == mEntityName) + return e; + } + return nullptr; +} + +Ogre::Entity* AddPrintPegsCommand::swapEntityMesh(const Ogre::MeshPtr& mesh) +{ + Manager* mgr = Manager::getSingletonPtr(); + if (!mgr || !mesh) + return nullptr; + Ogre::Entity* cur = resolveEntity(); + if (!cur) + return nullptr; + Ogre::SceneNode* node = cur->getParentSceneNode(); + if (!node) + return nullptr; + + // Drop every selection reference before freeing the entity (SplitMeshCommand + // rationale: dangling sub-entity refs crash the next selection query). + if (auto* sel = SelectionSet::getSingleton()) { + mReselectNode = sel->contains(node) ? node : nullptr; + sel->clearList(); + } + node->detachObject(cur); + mgr->getSceneMgr()->destroyEntity(cur); + Ogre::Entity* ne = mgr->createEntity(node, mesh); + if (ne && mReselectNode) { + if (auto* sel = SelectionSet::getSingleton()) + sel->selectOne(node); + } + return ne; +} + +void AddPrintPegsCommand::redo() +{ + if (!mBuilt) { + mBuilt = true; + Ogre::Entity* entity = resolveEntity(); + if (!entity || !entity->getMesh()) { + mError = QStringLiteral("no entity to prep"); + return; + } + mOriginalMesh = entity->getMesh(); // resident for undo. + + PartOpsMesh::PrintPrepOutcome po = + PartOpsMesh::addPrintPegsToEntity(entity, mOpts, + mEntityName + std::string("_pegged")); + if (!po.ok) { + mError = po.error.isEmpty() ? QStringLiteral("print prep failed") : po.error; + return; + } + mPeggedMesh = po.mesh; + mPeggedBoundaries = po.peggedBoundaries; + mTotalPegs = po.totalPegs; + mWarnings = po.warnings; + } + + if (!mPeggedMesh) { + mOk = false; + return; // build failed on first redo; mError set. + } + Ogre::Entity* ne = swapEntityMesh(mPeggedMesh); + mOk = (ne != nullptr); + if (mOk) + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.print_pegs"), + QStringLiteral("boundaries=%1 pegs=%2") + .arg(mPeggedBoundaries).arg(mTotalPegs)); + else if (mError.isEmpty()) + mError = QStringLiteral("failed to swap in pegged mesh"); +} + +void AddPrintPegsCommand::undo() +{ + if (!mOriginalMesh) + return; + swapEntityMesh(mOriginalMesh); +} diff --git a/src/commands/AddPrintPegsCommand.h b/src/commands/AddPrintPegsCommand.h new file mode 100644 index 00000000..1aa9f03b --- /dev/null +++ b/src/commands/AddPrintPegsCommand.h @@ -0,0 +1,64 @@ +#ifndef ADD_PRINT_PEGS_COMMAND_H +#define ADD_PRINT_PEGS_COMMAND_H + +#include +#include + +#include + +#include "SubMeshOps.h" + +#include +#include + +namespace Ogre { class Entity; class SceneNode; } + +/** + * Undoable PartOps print-prep (#859/#863): adds cylindrical alignment pegs to an + * already-SPLIT entity so its parts snap together for 3D printing. Each part + * that shares a stable boundary with another gains a male-peg / female-socket + * connector merged into its geometry. + * + * Adding pegs merges NEW triangles into existing submeshes (the part count is + * unchanged), so — like SplitMeshCommand — this swaps the whole mesh on the + * scene node rather than mutating buffers in place (the safe path for a geometry + * change). redo() runs `PartOpsMesh::addPrintPegsToEntity` once (cached), then + * swaps the pegged mesh onto the node; undo() restores the resident pre-peg mesh. + * Node and entity share a name, so the command targets by that name and survives + * scene rebuilds. Runs in Object mode. + */ +class AddPrintPegsCommand : public QUndoCommand +{ +public: + AddPrintPegsCommand(std::string entityName, + SubMeshOps::PegOptions opts, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + bool ok() const { return mOk; } + const QString& error() const { return mError; } + int peggedBoundaries() const { return mPeggedBoundaries; } + int totalPegs() const { return mTotalPegs; } + const std::vector& warnings() const { return mWarnings; } + +private: + Ogre::Entity* resolveEntity() const; + Ogre::Entity* swapEntityMesh(const Ogre::MeshPtr& mesh); + + std::string mEntityName; + SubMeshOps::PegOptions mOpts; + + Ogre::SceneNode* mReselectNode = nullptr; + Ogre::MeshPtr mOriginalMesh; ///< pre-peg mesh, resident for undo. + Ogre::MeshPtr mPeggedMesh; ///< built once on first redo. + bool mBuilt = false; + bool mOk = false; + QString mError; + int mPeggedBoundaries = 0; + int mTotalPegs = 0; + std::vector mWarnings; +}; + +#endif // ADD_PRINT_PEGS_COMMAND_H diff --git a/src/commands/AddPrintPegsCommand_test.cpp b/src/commands/AddPrintPegsCommand_test.cpp new file mode 100644 index 00000000..8cb8b340 --- /dev/null +++ b/src/commands/AddPrintPegsCommand_test.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "commands/AddPrintPegsCommand.h" +#include "SubMeshOps.h" + +// No-Ogre / error-branch coverage for AddPrintPegsCommand (mirrors +// SplitMeshCommand_test.cpp): ctor/text contract, accessor state before redo(), +// redo() against an unresolvable entity (→ ok()==false with an error), and +// undo() before any successful redo (strict no-op). The full split→peg→export +// round-trip is covered by the CLI print-pegs path (verified on Hip Hop +// Dancing.obj: 5 boundaries pegged) and the pure-data SubMeshOps peg tests. + +namespace { +const std::string kBogusEntity = "__qtmesh_nonexistent_entity_for_pegs_test__"; +} + +TEST(AddPrintPegsCommandTest, CtorSetsText) +{ + AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); + EXPECT_EQ(cmd.text(), QStringLiteral("Add Print Alignment Pegs")); +} + +TEST(AddPrintPegsCommandTest, InitialAccessorState) +{ + AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); + EXPECT_FALSE(cmd.ok()); + EXPECT_EQ(cmd.peggedBoundaries(), 0); + EXPECT_EQ(cmd.totalPegs(), 0); + EXPECT_TRUE(cmd.warnings().empty()); +} + +TEST(AddPrintPegsCommandTest, RedoOnUnresolvableEntityFailsCleanly) +{ + AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); + cmd.redo(); // no scene / no entity → error branch + EXPECT_FALSE(cmd.ok()); + EXPECT_FALSE(cmd.error().isEmpty()); + EXPECT_EQ(cmd.totalPegs(), 0); +} + +TEST(AddPrintPegsCommandTest, UndoBeforeRedoIsNoOp) +{ + AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); + EXPECT_NO_THROW(cmd.undo()); + EXPECT_FALSE(cmd.ok()); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 298f88b4..7f3d9e87 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -192,6 +192,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/SplitMeshCommand.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AddPrintPegsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ExplodePartsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/JoinPartsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/SkeletonBoneCommands.cpp From 4d1bd82543fdbfe26635e3efeb005012ef762cdf Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 27 Jul 2026 21:11:11 -0400 Subject: [PATCH 02/12] feat(#863): cap split parts into watertight solids (close the open cut face) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on the pegs: the split parts are hollow shells — you can see into the open cut face where they were separated, and the peg sits in that gap. For 3D printing each part must be a closed solid, and the peg needs a real surface to attach to. - SubMeshOps::capOpenBoundaries (pure-data): finds a part's boundary edges (a directed edge whose reverse is absent → the open rim a split leaves), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Copies a rim vertex's attributes onto the new centre so the cap shares the part's material/uv space. - Applied AUTOMATICALLY inside preparePrintPegs (cap all parts before pegging, so the pegs sit on a solid face; boundary planes are still estimated from the original uncapped submeshes, so seam detection is unaffected). Reported as PrintPrepResult::cappedParts. - Exposed as an opt-in explode toggle: "Cap open boundaries (watertight)" checkbox → explodeSelected(distance, capBoundaries) → ExplodePartsCommand / PartOpsScene::explodeEntity capBoundaries param — so an exploded part is a closed solid too. Verified end to end on Hip Hop Dancing.obj: the exploded legs' top cut faces are now solid green caps (were hollow holes), confirmed via the MCP RTT screenshot. Tests: CapOpenBoundaryClosesHole (open-top box → 1 cap, +1 centre vert, +4 tris) and CapOpenBoundariesNoOpWhenClosed (closed tetrahedron). 31 PartOps tests pass; GUI loads clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- qml/PropertiesPanel.qml | 12 +++- src/PartOpsController.cpp | 4 +- src/PartOpsController.h | 2 +- src/PartOpsScene.cpp | 10 ++- src/PartOpsScene.h | 3 +- src/SubMeshOps.cpp | 101 +++++++++++++++++++++++++++ src/SubMeshOps.h | 17 +++++ src/SubMeshOps_test.cpp | 46 ++++++++++++ src/commands/ExplodePartsCommand.cpp | 6 +- src/commands/ExplodePartsCommand.h | 5 +- 11 files changed, 198 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02032169..b4da5651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg/socket geometry). **`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) — print-split prep with alignment pegs**: `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam, and where stable builds matching cylindrical pegs via `buildAlignmentPegs` — the MALE peg merged into partA + the female SOCKET into partB as extra `connector_male`/`connector_socket` geometry, so each part stays one printable object. Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), 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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg/socket geometry). **`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) — print-split prep with alignment pegs**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow — bad for printing, and a peg needs a solid face): it finds boundary edges (a directed edge whose reverse is absent), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Applied inside print-prep (always, before pegs) and as an opt-in explode toggle ("Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries` param), so an exploded/pegged part is a watertight solid. `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam, and where stable builds matching cylindrical pegs via `buildAlignmentPegs` — the MALE peg merged into partA + the female SOCKET into partB as extra `connector_male`/`connector_socket` geometry, so each part stays one printable object. Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), F docs. - **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/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 8372fadb..5fa5265e 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -6572,6 +6572,15 @@ Rectangle { } } + // Close each part's open cut face so exploded parts are watertight + // solids (#863) — useful before 3D printing. + property bool capBoundaries: false + InspectorCheckBox { + text: "Cap open boundaries (watertight)" + checked: partOpsEjContent.capBoundaries + onCheckedChanged: partOpsEjContent.capBoundaries = checked + } + // --- Explode button --- Rectangle { id: partOpsExplodeBtn @@ -6603,7 +6612,8 @@ Rectangle { onClicked: { partOpsEjFeedback.color = PropertiesPanelController.textColor partOpsEjFeedback.text = "Exploding…" - PartOpsController.explodeSelected(partOpsEjContent.explodeDistance) + PartOpsController.explodeSelected(partOpsEjContent.explodeDistance, + partOpsEjContent.capBoundaries) } } } diff --git a/src/PartOpsController.cpp b/src/PartOpsController.cpp index c4624441..145ca3fa 100644 --- a/src/PartOpsController.cpp +++ b/src/PartOpsController.cpp @@ -112,7 +112,7 @@ void PartOpsController::splitSelectedIntoParts(const QString& upAxis, const QStr emit splitFinished(tr("Split into %1 part submeshes.").arg(cmd->createdSubMeshes()), false); } -void PartOpsController::explodeSelected(double distance) +void PartOpsController::explodeSelected(double distance, bool capBoundaries) { const auto* sel = SelectionSet::getSingleton(); if (!sel) { @@ -131,7 +131,7 @@ void PartOpsController::explodeSelected(double distance) SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("explode_parts")); const std::string entName = entities.first()->getName(); - auto* cmd = new ExplodePartsCommand(entName, static_cast(distance)); + auto* cmd = new ExplodePartsCommand(entName, static_cast(distance), capBoundaries); UndoManager::getSingleton()->push(cmd); if (!cmd->ok()) { diff --git a/src/PartOpsController.h b/src/PartOpsController.h index a2d05b38..ead8ff2f 100644 --- a/src/PartOpsController.h +++ b/src/PartOpsController.h @@ -60,7 +60,7 @@ class PartOpsController : public QObject * (undoable). Each part is pushed outward by `distance` × the assembly * diagonal. Emits explodeFinished(status, isError). No-op (error) without * a single multi-submesh selection. */ - Q_INVOKABLE void explodeSelected(double distance = 0.5); + Q_INVOKABLE void explodeSelected(double distance = 0.5, bool capBoundaries = false); /** Join the selected part entities (2+) back into one fused mesh, baking * their world transforms into vertices (undoable). Emits diff --git a/src/PartOpsScene.cpp b/src/PartOpsScene.cpp index ebf152f6..7038ffcc 100644 --- a/src/PartOpsScene.cpp +++ b/src/PartOpsScene.cpp @@ -25,7 +25,8 @@ Ogre::Vector3 subMeshCentroid(const EditableSubMesh& sub) } // namespace PartOpsScene::ExplodeResult -PartOpsScene::explodeEntity(Ogre::Entity* entity, float distance, const std::string& baseName) +PartOpsScene::explodeEntity(Ogre::Entity* entity, float distance, + const std::string& baseName, bool capBoundaries) { ExplodeResult out; if (!entity || !entity->getMesh()) { @@ -43,6 +44,13 @@ PartOpsScene::explodeEntity(Ogre::Entity* entity, float distance, const std::str return out; } + // Optionally close each part's open cut face so an exploded part is a + // watertight solid (#863). Done on the read-out copies before per-part mesh + // build; centroids/bounds below are computed from the (capped) copies. + if (capBoundaries) + for (auto& s : subs) + SubMeshOps::capOpenBoundaries(s); + QString skelName; if (entity->getMesh()->hasSkeleton()) skelName = QString::fromStdString(entity->getMesh()->getSkeletonName()); diff --git a/src/PartOpsScene.h b/src/PartOpsScene.h index 816d3f30..6e5f6a7f 100644 --- a/src/PartOpsScene.h +++ b/src/PartOpsScene.h @@ -64,7 +64,8 @@ class PartOpsScene * geometry, or a single-submesh mesh (nothing to explode). */ static ExplodeResult explodeEntity(Ogre::Entity* entity, float distance, - const std::string& baseName); + const std::string& baseName, + bool capBoundaries = false); // ------------------------------------------------------------------------- // Join: N part entities (with world transforms) -> one fused mesh. diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index fd5ea835..4e4160f8 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -421,6 +421,100 @@ SubMeshOps::explodeOffsets(const std::vector& partCentroids, return offsets; } +int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) +{ + const size_t triCount = sub.triangles.size(); + if (triCount == 0 || sub.vertices.empty()) + return 0; + + // 1) Boundary edges = directed edges whose REVERSE is not also present. In a + // closed manifold every edge appears once in each direction; an open cut + // face leaves its rim edges with no opposite. Key by the ordered vertex + // pair so we can find the unmatched ones, and remember the directed edge + // (a→b) so the cap can be wound consistently with the source triangles. + auto key = [](unsigned int a, unsigned int b) -> uint64_t { + return (static_cast(a) << 32) | b; + }; + std::unordered_map dirCount; // directed edge → count + for (const EditableTriangle& t : sub.triangles) { + dirCount[key(t.indices[0], t.indices[1])]++; + dirCount[key(t.indices[1], t.indices[2])]++; + dirCount[key(t.indices[2], t.indices[0])]++; + } + // A directed edge a→b is a boundary edge when b→a is absent. Build the + // successor map next[a] = b over boundary edges to walk the loops. + std::unordered_map next; + for (const auto& kv : dirCount) { + const unsigned int a = static_cast(kv.first >> 32); + const unsigned int b = static_cast(kv.first & 0xffffffff); + if (dirCount.find(key(b, a)) == dirCount.end()) + next[a] = b; // boundary edge a→b (the interior is to its left) + } + if (next.empty()) + return 0; // already closed + + // Part centroid — used to orient each cap OUTWARD. + Ogre::Vector3 partC = Ogre::Vector3::ZERO; + for (const auto& v : sub.vertices) partC += v.position; + partC /= static_cast(sub.vertices.size()); + + // 2) Walk each boundary loop from an unvisited start, following next[]. + int caps = 0; + std::unordered_map visited; + for (const auto& seed : next) { + const unsigned int start = seed.first; + if (visited.count(start)) + continue; + std::vector loop; + unsigned int cur = start; + while (next.count(cur) && !visited.count(cur)) { + visited[cur] = true; + loop.push_back(cur); + cur = next[cur]; + if (cur == start) break; // closed + } + if (loop.size() < 3) + continue; + + // 3) Centroid-fan fill. New centre vertex copies a rim vertex's + // attributes (material/uv space) with the averaged position. + Ogre::Vector3 c = Ogre::Vector3::ZERO; + for (unsigned int vi : loop) c += sub.vertices[vi].position; + c /= static_cast(loop.size()); + EditableVertex centre = sub.vertices[loop[0]]; + centre.position = c; + centre.hasNormal = false; // recomputed after (or by createNewMesh) + const unsigned int cIdx = static_cast(sub.vertices.size()); + sub.vertices.push_back(centre); + + // Winding: the boundary edge a→b has the part interior on its LEFT, so a + // fan triangle (centre, a, b) faces the SAME way as the missing cap. Test + // one triangle's normal against the outward direction (centre→partC) and + // flip all if it points inward. + const unsigned int a0 = loop[0], b0 = loop[1]; + const Ogre::Vector3 n0 = (sub.vertices[a0].position - c) + .crossProduct(sub.vertices[b0].position - c); + const bool flip = n0.dotProduct(c - partC) < 0.0f; // want normal away from partC + const size_t nEdges = loop.size(); + for (size_t i = 0; i < nEdges; ++i) { + const unsigned int a = loop[i]; + const unsigned int b = loop[(i + 1) % nEdges]; + EditableTriangle t; + t.indices[0] = cIdx; + t.indices[1] = flip ? b : a; + t.indices[2] = flip ? a : b; + sub.triangles.push_back(t); + } + ++caps; + } + + // Cap triangles were appended; drop any stale n-gon `faces` binding so the + // triangle list is authoritative downstream (buildSubMeshBuffers rebuilds). + if (caps > 0) + sub.faces.clear(); + return caps; +} + SubMeshOps::BoundaryPlane SubMeshOps::estimateBoundaryPlane(const std::vector& partA, const std::vector& partB, float weldTol) @@ -641,6 +735,13 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, out.subMeshes = subMeshes; // start from the parts; merge pegs in below. out.partNames = partNames; out.partNames.resize(subMeshes.size()); + + // Close each part's OPEN cut face first (a split leaves it hollow) so every + // part is a watertight printable solid and the pegs attach to a real + // surface. Boundary planes are still estimated from the ORIGINAL (uncapped) + // submeshes below, so the coincident-seam detection is unaffected by the cap. + for (auto& part : out.subMeshes) + out.cappedParts += (capOpenBoundaries(part) > 0) ? 1 : 0; auto nameOf = [&](int i) -> QString { return (i >= 0 && i < static_cast(out.partNames.size()) && !out.partNames[i].isEmpty()) ? out.partNames[i] : QStringLiteral("part%1").arg(i); diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h index b7ffbcff..704b0f46 100644 --- a/src/SubMeshOps.h +++ b/src/SubMeshOps.h @@ -156,6 +156,22 @@ class SubMeshOps const Ogre::AxisAlignedBox& assemblyBounds, float distance); + // ------------------------------------------------------------------------- + // Boundary capping (#863) — close the open cut face of a split part + // ------------------------------------------------------------------------- + + /** Cap the OPEN boundary of a split part so it becomes a watertight solid + * (a split leaves the cut face as a hole — bad for 3D printing, and a peg + * needs a solid face to attach to). Finds every boundary edge (an edge used + * by exactly ONE triangle), chains them into closed loops, and fills each + * loop with a CENTROID FAN: one new vertex at the loop's centroid + a + * triangle per boundary edge, wound so the cap faces OUTWARD (away from the + * part's centroid). Copies a representative boundary vertex's attributes + * onto the new centroid verts so the cap shares the part's material/uv + * space. Edits `sub` in place; returns the number of caps (loops) filled. + * Deterministic; pure-data. Skips loops shorter than 3 edges. */ + static int capOpenBoundaries(EditableSubMesh& sub); + // ------------------------------------------------------------------------- // Print-split alignment pegs (Slice D #863) // ------------------------------------------------------------------------- @@ -218,6 +234,7 @@ class SubMeshOps std::vector boundaries; ///< every pair considered (diag). int peggedBoundaries = 0; int totalPegs = 0; + int cappedParts = 0; ///< parts whose open cut face was closed. }; /** Prepare a split mesh for 3D printing (Slice D #863). For EVERY pair of diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index 1f940973..393829a4 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -502,3 +502,49 @@ TEST(SubMeshOpsTest, PreparePrintPegsNeedsTwoParts) EXPECT_FALSE(r.ok); EXPECT_FALSE(r.error.isEmpty()); } + +// ---- capOpenBoundaries (#863 close split cut face) ------------------------ + +TEST(SubMeshOpsTest, CapOpenBoundaryClosesHole) +{ + // An open-topped box: 8 cube corners, all 5 side+bottom faces, TOP missing. + // The top rim (verts 4,5,6,7 at y=1) is one open boundary loop of 4 edges. + // capOpenBoundaries should fill it → 1 cap, +1 centre vert, +4 triangles. + EditableSubMesh s; + s.materialName = "Box"; + // bottom (y=0): 0,1,2,3 top (y=1): 4,5,6,7 + auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); return v; }; + s.vertices = { V(0,0,0),V(1,0,0),V(1,0,1),V(0,0,1), + V(0,1,0),V(1,1,0),V(1,1,1),V(0,1,1) }; + auto Q = [&](unsigned a,unsigned b,unsigned c,unsigned d){ addTri(s,a,b,c); addTri(s,a,c,d); }; + Q(0,1,2,3); // bottom + Q(0,4,5,1); // front + Q(1,5,6,2); // right + Q(2,6,7,3); // back + Q(3,7,4,0); // left + // NO top → verts 4,5,6,7 form the open rim. + + const size_t triBefore = s.triangles.size(); + const size_t vBefore = s.vertices.size(); + const int caps = SubMeshOps::capOpenBoundaries(s); + EXPECT_EQ(caps, 1); + EXPECT_EQ(s.vertices.size(), vBefore + 1); // one centroid vertex + EXPECT_EQ(s.triangles.size(), triBefore + 4); // one tri per rim edge + // The new centre vertex sits at the rim centroid (0.5,1,0.5). + const auto& cv = s.vertices.back(); + EXPECT_NEAR(cv.position.x, 0.5f, 1e-4f); + EXPECT_NEAR(cv.position.y, 1.0f, 1e-4f); + EXPECT_NEAR(cv.position.z, 0.5f, 1e-4f); +} + +TEST(SubMeshOpsTest, CapOpenBoundariesNoOpWhenClosed) +{ + // A closed tetrahedron: every edge is shared by two faces → no boundary. + EditableSubMesh s; s.materialName = "Tet"; + auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); return v; }; + s.vertices = { V(0,0,0), V(1,0,0), V(0,1,0), V(0,0,1) }; + addTri(s,0,2,1); addTri(s,0,1,3); addTri(s,0,3,2); addTri(s,1,2,3); + const size_t before = s.triangles.size(); + EXPECT_EQ(SubMeshOps::capOpenBoundaries(s), 0); + EXPECT_EQ(s.triangles.size(), before); +} diff --git a/src/commands/ExplodePartsCommand.cpp b/src/commands/ExplodePartsCommand.cpp index a0eac5bd..efd65366 100644 --- a/src/commands/ExplodePartsCommand.cpp +++ b/src/commands/ExplodePartsCommand.cpp @@ -33,10 +33,11 @@ void reparentAndSetLocal(Manager* mgr, Ogre::SceneNode* node, } // namespace ExplodePartsCommand::ExplodePartsCommand(std::string entityName, float distance, - QUndoCommand* parent) + bool capBoundaries, QUndoCommand* parent) : QUndoCommand(parent) , mEntityName(std::move(entityName)) , mDistance(distance) + , mCapBoundaries(capBoundaries) { setText(QStringLiteral("Explode into Parts")); } @@ -89,7 +90,8 @@ void ExplodePartsCommand::buildOnce() mParentNodeName = parent->getName(); PartOpsScene::ExplodeResult r = - PartOpsScene::explodeEntity(entity, mDistance, mEntityName + std::string("_part")); + PartOpsScene::explodeEntity(entity, mDistance, mEntityName + std::string("_part"), + mCapBoundaries); if (!r.ok) { mError = r.error.isEmpty() ? QStringLiteral("explode failed") : r.error; return; diff --git a/src/commands/ExplodePartsCommand.h b/src/commands/ExplodePartsCommand.h index a37a1ea9..bd66d5e7 100644 --- a/src/commands/ExplodePartsCommand.h +++ b/src/commands/ExplodePartsCommand.h @@ -37,8 +37,10 @@ class ExplodePartsCommand : public QUndoCommand { public: /** @param entityName the fused entity to explode (== its node name). - * @param distance explode offset multiplier (× assembly diagonal). */ + * @param distance explode offset multiplier (× assembly diagonal). + * @param capBoundaries close each part's open cut face (#863). */ ExplodePartsCommand(std::string entityName, float distance, + bool capBoundaries = false, QUndoCommand* parent = nullptr); void undo() override; @@ -56,6 +58,7 @@ class ExplodePartsCommand : public QUndoCommand std::string mEntityName; float mDistance = 0.5f; + bool mCapBoundaries = false; struct PartCache { Ogre::MeshPtr mesh; ///< single-submesh part mesh (resident for redo). From 9f3a667ae865136d4b4dc33cabf56f9b99f51375 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 27 Jul 2026 23:09:57 -0400 Subject: [PATCH 03/12] fix(#863): real Manifold boolean sockets + green/red connectors (PR #932 reviews) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the Slice-D print-peg review comments: - Female SOCKET is now a REAL cavity cut into the mating part via the Manifold mesh-boolean (`subtractSockets`) — not a solid cylinder appended as fake geometry (P1/Major, Codex+CodeRabbit). Vendored `elalish/manifold` v3.0.1 (MIT) via FetchContent alongside meshoptimizer/xatlas, configured lean. - Boundary normal is oriented from the MALE part (A) toward the FEMALE part (B) via body centroids before extruding the peg — the best-fit eigenvector sign is arbitrary (P1/Major). - Male peg → shared GREEN `connector_male` submesh; each socket mouth gets a shallow RED `connector_socket` collar ring, so both connectors are visible and distinctly coloured (self-lit materials created by `PartOpsMesh::ensureConnectorMaterials`). Connectors are appended as their own named submeshes. - Connector vertices inherit the nearest part vertex's bone weights on a skinned mesh (`inheritNearestBoneWeights`) so pegs follow their part instead of collapsing to the skeleton origin (P2). The boolean-cut cavity walls already carry part-B weights via nearest-source attribute re-derivation. - CLI `segment --print-pegs` without `-o` now reports `--print-pegs` (not `--split-parts`) in the usage error (Minor). Tests: SubMeshOps peg suite updated (2 coloured connector submeshes + boolean cavity vertex-count change); new bone-weight inheritance test; new GL redo/undo round-trip for AddPrintPegsCommand on the real Rumba fixture (12-part split → peg → undo). All green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- CMakeLists.txt | 24 ++ src/CLIPipeline.cpp | 3 +- ...LIPipeline_cmdsplitparts_coverage_test.cpp | 75 +++++ src/CMakeLists.txt | 3 +- src/PartOpsMesh.cpp | 32 ++ src/SubMeshOps.cpp | 296 +++++++++++++++++- src/SubMeshOps_test.cpp | 54 +++- tests/CMakeLists.txt | 1 + 9 files changed, 476 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b4da5651..34f6b28f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg/socket geometry). **`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) — print-split prep with alignment pegs**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow — bad for printing, and a peg needs a solid face): it finds boundary edges (a directed edge whose reverse is absent), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Applied inside print-prep (always, before pegs) and as an opt-in explode toggle ("Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries` param), so an exploded/pegged part is a watertight solid. `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam, and where stable builds matching cylindrical pegs via `buildAlignmentPegs` — the MALE peg merged into partA + the female SOCKET into partB as extra `connector_male`/`connector_socket` geometry, so each part stays one printable object. Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), 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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg geometry; the female SOCKET is a REAL cavity cut via the Manifold mesh-boolean, not solid geometry). **`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) — print-split prep with alignment pegs**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow — bad for printing, and a peg needs a solid face): it finds boundary edges (a directed edge whose reverse is absent), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Applied inside print-prep (always, before pegs) and as an opt-in explode toggle ("Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries` param), so an exploded/pegged part is a watertight solid. `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam (oriented from the MALE part A toward the FEMALE part B via body centroids — the eigenvector sign is arbitrary but the peg extrudes along +normal), and where stable builds matching cylindrical pegs via `buildAlignmentPegs`. The MALE peg is collected into a shared **`connector_male`** submesh (rendered GREEN); the female SOCKET is a **real cylindrical CAVITY** cut into part B via the **Manifold** mesh-boolean (`subtractSockets` — EditableSubMesh→`manifold::Manifold`, subtract one `Cylinder` per peg center, convert back re-deriving per-vertex attributes by nearest-source lookup; falls soft to leaving the part untouched if the boolean throws), plus a shallow **`connector_socket`** collar ring at each socket mouth (rendered RED) so the female side is visible. Both connector submeshes are appended after the parts with their own names/materials. **Manifold** (elalish/manifold, MIT, robust mesh CSG) is vendored via FetchContent (v3.0.1, configured lean — no tests/exports/parallel/bindings) alongside meshoptimizer/xatlas; `PartOpsMesh::ensureConnectorMaterials` creates the green/red self-lit materials on demand. Peg-ring centers are reproduced from `buildAlignmentPegs`'s placement via the shared `pegRingCenters` helper so the socket cutters line up 1:1 with the male pegs. Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), F docs. - **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/CMakeLists.txt b/CMakeLists.txt index 6633108a..eb97dfb0 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -425,6 +425,30 @@ if(NOT TARGET xatlas) endif() message(STATUS "xatlas enabled (auto UV unwrap)") +############################################################## +# Manifold — robust mesh boolean (CSG). MIT. Used to cut real +# female SOCKET cavities for the 3D-print alignment pegs +# (PartOps #863). Configured lean: no exports/tests/parallel/ +# cross-section/embind — just the core boolean library. +############################################################## +set(MANIFOLD_TEST OFF CACHE BOOL "" FORCE) +set(MANIFOLD_EXPORT OFF CACHE BOOL "" FORCE) +set(MANIFOLD_PAR OFF CACHE BOOL "" FORCE) +set(MANIFOLD_CROSS_SECTION OFF CACHE BOOL "" FORCE) +set(MANIFOLD_EXCEPTIONS ON CACHE BOOL "" FORCE) +set(MANIFOLD_DEBUG OFF CACHE BOOL "" FORCE) +set(MANIFOLD_PYBIND OFF CACHE BOOL "" FORCE) +set(MANIFOLD_CBIND OFF CACHE BOOL "" FORCE) +set(MANIFOLD_JSBIND OFF CACHE BOOL "" FORCE) +FetchContent_Declare( + manifold + GIT_REPOSITORY https://github.com/elalish/manifold.git + GIT_TAG v3.0.1 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(manifold) +message(STATUS "manifold enabled (mesh boolean — print-peg sockets)") + ############################################################## # stb — Radiance .hdr decode for HDR environment maps (#467). ############################################################## diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 598aa158..775b2d59 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10439,7 +10439,8 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) QFileInfo fi(inputPath); if (!fi.exists()) { err() << "Error: file not found: " << inputPath << Qt::endl; return 1; } if (splitParts && outputPath.isEmpty()) { - err() << "Error: --split-parts requires -o ." << Qt::endl; + err() << "Error: " << (printPegs ? "--print-pegs" : "--split-parts") + << " requires -o ." << Qt::endl; return 2; } if (!initOgreHeadless()) return 1; diff --git a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp index 50d7466a..fe3694d4 100644 --- a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -32,6 +32,10 @@ #include "TestHelpers.h" #include "MeshSegmenter.h" #include "EditableMesh.h" +#include "commands/AddPrintPegsCommand.h" + +#include +#include #include #include @@ -229,6 +233,77 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitRiggedHumanoidPreservesTrisAnd } } +// AddPrintPegsCommand GL round-trip (#863): on a real SPLIT entity in the scene, +// redo() swaps in a pegged mesh (adding the green/red connector submeshes) and +// undo() restores the exact pre-peg mesh. Exercises the command's successful +// redo/undo path — not just the error branches in AddPrintPegsCommand_test.cpp. +TEST_F(CLIPipelineCmdSplitPartsCoverageTest, AddPrintPegsCommandRedoUndoRoundTrip) +{ + const QString fixture = riggedFixture(); + if (fixture.isEmpty()) + GTEST_SKIP() << "rigged fixture not present; peg round-trip needs a multi-part mesh"; + + // 1) Split the rigged fixture into per-part submeshes → FBX. + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outFbx = QDir(tmp.path()).filePath("split_for_pegs.fbx"); + clearScene(); + { + const QByteArray in = fixture.toUtf8(); + const QByteArray out = outFbx.toUtf8(); + SplitArgv args({"qtmesh", "segment", in.constData(), "--no-model", + "--split-parts", "-o", out.constData()}); + ASSERT_EQ(0, CLIPipeline::cmdSegment(args.argc(), args.argv())); + } + ASSERT_TRUE(QFile::exists(outFbx)); + + // 2) Reimport the split mesh so a live multi-submesh entity is in the scene. + clearScene(); + MeshImporterExporter::importer({QFileInfo(outFbx).absoluteFilePath()}); + auto& entities = Manager::getSingleton()->getEntities(); + ASSERT_FALSE(entities.isEmpty()); + Ogre::Entity* e = entities.first(); + ASSERT_NE(e, nullptr); + const std::string entityName = e->getName(); + const unsigned short subMeshesBefore = e->getMesh()->getNumSubMeshes(); + ASSERT_GT(subMeshesBefore, 1u) << "peg command needs a multi-part mesh"; + + // 3) redo(): build + swap in the pegged mesh. + SubMeshOps::PegOptions opts; // defaults; auto-fits the boundary + AddPrintPegsCommand cmd(entityName, opts); + cmd.redo(); + ASSERT_TRUE(cmd.ok()) << cmd.error().toStdString(); + + Ogre::Entity* pegged = nullptr; + for (Ogre::Entity* cand : Manager::getSingleton()->getEntities()) + if (cand && cand->getMovableType() == "Entity" && cand->getName() == entityName) + pegged = cand; + ASSERT_NE(pegged, nullptr) << "pegged entity not found after redo"; + if (cmd.peggedBoundaries() > 0) { + // The male + socket connector submeshes were appended (2 extra parts). + EXPECT_GT(pegged->getMesh()->getNumSubMeshes(), subMeshesBefore) + << "pegging should append connector submeshes"; + EXPECT_GT(cmd.totalPegs(), 0); + // At least one submesh carries a connector material. + bool hasConnector = false; + for (unsigned short i = 0; i < pegged->getNumSubEntities(); ++i) { + const std::string m = pegged->getSubEntity(i)->getMaterialName(); + if (m == "connector_male" || m == "connector_socket") { hasConnector = true; break; } + } + EXPECT_TRUE(hasConnector) << "no connector_male/connector_socket submesh after pegging"; + } + + // 4) undo(): the pre-peg mesh (same submesh count) is restored. + cmd.undo(); + Ogre::Entity* restored = nullptr; + for (Ogre::Entity* cand : Manager::getSingleton()->getEntities()) + if (cand && cand->getMovableType() == "Entity" && cand->getName() == entityName) + restored = cand; + ASSERT_NE(restored, nullptr) << "entity missing after undo"; + EXPECT_EQ(restored->getMesh()->getNumSubMeshes(), subMeshesBefore) + << "undo must restore the exact pre-peg submesh count"; +} + // --split-parts without -o is a usage error (exit 2), no Ogre load required. TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitPartsRequiresOutput) { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b8cc5362..15181176 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -704,6 +704,7 @@ Qt::Quick Qt::QuickWidgets Qt::QuickControls2 meshoptimizer +manifold xatlas qtmesh_sodium ) @@ -892,7 +893,7 @@ if(BUILD_TESTS) ${OGRE_LIBRARIES} ${ASSIMP_LIBRARIES} Qt::Widgets Qt::Core Qt::Gui Qt::Test Qt::Network Qt::Qml Qt::Quick Qt::QuickWidgets Qt::QuickControls2 - meshoptimizer xatlas qtmesh_sodium qtmesh_updater) + meshoptimizer manifold xatlas qtmesh_sodium qtmesh_updater) if(stb_SOURCE_DIR) target_include_directories(UnitTests PRIVATE ${stb_SOURCE_DIR}) diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index 4c0739a0..530e1f8b 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -6,9 +6,38 @@ #include #include #include +#include +#include +#include +#include #include +namespace { +// Create (once) a solid-coloured, self-lit material so the print connectors are +// unmistakable: green = male peg, red = female socket collar. Idempotent. +void ensureConnectorMaterial(const std::string& name, const Ogre::ColourValue& c) +{ + auto& mm = Ogre::MaterialManager::getSingleton(); + if (mm.resourceExists(name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) + return; + Ogre::MaterialPtr mat = mm.create(name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + Ogre::Pass* pass = mat->getTechnique(0)->getPass(0); + pass->setDiffuse(c); + pass->setAmbient(c); + pass->setSelfIllumination(c * 0.6f); // glow a bit so it reads even unlit + pass->setSpecular(Ogre::ColourValue(0.2f, 0.2f, 0.2f, 1.0f)); + pass->setShininess(16.0f); + mat->compile(); +} + +void ensureConnectorMaterials() +{ + ensureConnectorMaterial("connector_male", Ogre::ColourValue(0.15f, 0.80f, 0.20f, 1.0f)); + ensureConnectorMaterial("connector_socket", Ogre::ColourValue(0.85f, 0.15f, 0.15f, 1.0f)); +} +} // namespace + bool PartOpsMesh::readSubMeshes(Ogre::Entity* entity, std::vector& outSubMeshes) { @@ -48,6 +77,9 @@ Ogre::MeshPtr PartOpsMesh::buildMesh(const std::vector& subMesh { if (subMeshes.empty()) return Ogre::MeshPtr(); + // Print connectors reference the green/red connector_* materials — make sure + // they exist (a no-op when the mesh has none). + ensureConnectorMaterials(); // EditableMesh::createNewMesh is the canonical build-a-fresh-mesh path // (createSubMesh per part + buildSubMeshBuffers + normals + bounds). We // borrow it by seeding an EditableMesh's submesh vector directly. diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index 4e4160f8..ba6d0b1f 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -7,6 +7,8 @@ #include #include +#include + namespace { // A vertex key that survives cross-submesh comparison for boundary welding: @@ -708,7 +710,7 @@ int SubMeshOps::buildAlignmentPegs(const BoundaryPlane& plane, const PegOptions& namespace { // Append `src`'s vertices + triangles onto `dst` (offsetting the indices by -// dst's current vertex count). Used to merge a peg/socket cylinder into a part. +// dst's current vertex count). Used to merge a peg cylinder into a part. void appendGeometry(EditableSubMesh& dst, const EditableSubMesh& src) { const unsigned int base = static_cast(dst.vertices.size()); @@ -721,6 +723,229 @@ void appendGeometry(EditableSubMesh& dst, const EditableSubMesh& src) dst.triangles.push_back(nt); } } + +// Give every vertex in `sub` that lacks bone weights the bone assignments of its +// nearest vertex in `source` — so connector geometry (peg / socket collar) on a +// SKINNED part rigidly follows the part it attaches to instead of collapsing to +// the skeleton origin (a vertex with no weights binds to bone 0 at weight 0). +// No-op when the source part has no weights (static mesh). +void inheritNearestBoneWeights(EditableSubMesh& sub, const EditableSubMesh& source) +{ + bool sourceSkinned = false; + for (const EditableVertex& v : source.vertices) + if (!v.boneAssignments.empty()) { sourceSkinned = true; break; } + if (!sourceSkinned || source.vertices.empty()) + return; + for (EditableVertex& v : sub.vertices) { + if (!v.boneAssignments.empty()) + continue; + const EditableVertex* best = nullptr; + float bestD = std::numeric_limits::max(); + for (const EditableVertex& sv : source.vertices) { + if (sv.boneAssignments.empty()) + continue; + const float d = sv.position.squaredDistance(v.position); + if (d < bestD) { bestD = d; best = &sv; } + } + if (best) + v.boneAssignments = best->boneAssignments; + } +} + +// Append a short, thick RING (annular collar) at a socket mouth to `sub`, +// centered at `center`, in the plane normal to `axis`. Inner radius = `r` +// (the socket bore), outer = 1.35·r, thickness `t` along +axis. Purely a +// visible red marker for the female side; renders as a flat washer. +void appendSocketCollar(EditableSubMesh& sub, const Ogre::Vector3& center, + const Ogre::Vector3& axis, float r, int segments) +{ + const Ogre::Vector3 n = axis.normalisedCopy(); + const float rOuter = r * 1.35f; + const float t = r * 0.12f; // shallow washer thickness + Ogre::Vector3 up = std::fabs(n.y) < 0.9f ? Ogre::Vector3::UNIT_Y : Ogre::Vector3::UNIT_X; + Ogre::Vector3 u = n.crossProduct(up).normalisedCopy(); + Ogre::Vector3 w = n.crossProduct(u).normalisedCopy(); + const Ogre::Vector3 front = center + n * (t * 0.5f); + const Ogre::Vector3 back = center - n * (t * 0.5f); + + auto addVert = [&](const Ogre::Vector3& p, const Ogre::Vector3& nrm) { + EditableVertex v; v.position = p; v.normal = nrm; v.hasNormal = true; + sub.vertices.push_back(v); + return static_cast(sub.vertices.size() - 1); + }; + auto addTri = [&](unsigned int a, unsigned int b, unsigned int c) { + EditableTriangle tri; tri.indices[0] = a; tri.indices[1] = b; tri.indices[2] = c; + sub.triangles.push_back(tri); + }; + std::vector fi(segments), fo(segments), bi(segments), bo(segments); + for (int i = 0; i < segments; ++i) { + const float a = 2.0f * Ogre::Math::PI * float(i) / float(segments); + const Ogre::Vector3 rad = (u * std::cos(a) + w * std::sin(a)); + fi[i] = addVert(front + rad * r, n); + fo[i] = addVert(front + rad * rOuter, n); + bi[i] = addVert(back + rad * r, -n); + bo[i] = addVert(back + rad * rOuter, -n); + } + for (int i = 0; i < segments; ++i) { + const int j = (i + 1) % segments; + // front face (facing +n) + addTri(fi[i], fo[i], fo[j]); addTri(fi[i], fo[j], fi[j]); + // back face (facing -n) + addTri(bi[i], bo[j], bo[i]); addTri(bi[i], bi[j], bo[j]); + // outer wall + addTri(fo[i], bo[i], bo[j]); addTri(fo[i], bo[j], fo[j]); + // inner wall + addTri(fi[i], bi[j], bi[i]); addTri(fi[i], fi[j], bi[j]); + } +} + +// Reproduce the exact peg-ring centers that buildAlignmentPegs() places, so the +// SOCKET boolean cutters line up 1:1 with the male pegs. `made` is the peg count +// buildAlignmentPegs actually produced (it clamps a too-small boundary to 1). +std::vector pegRingCenters(const SubMeshOps::BoundaryPlane& plane, + const SubMeshOps::PegOptions& opts, int made) +{ + std::vector centers; + if (made <= 0) + return centers; + const float placeRadius = plane.radius * 0.5f; + Ogre::Vector3 up = std::fabs(plane.normal.y) < 0.9f ? Ogre::Vector3::UNIT_Y + : Ogre::Vector3::UNIT_X; + Ogre::Vector3 u = plane.normal.crossProduct(up).normalisedCopy(); + Ogre::Vector3 w = plane.normal.crossProduct(u).normalisedCopy(); + for (int i = 0; i < made; ++i) { + Ogre::Vector3 center = plane.center; + if (made > 1) { + const float a = 2.0f * Ogre::Math::PI * float(i) / float(made); + center += (u * std::cos(a) + w * std::sin(a)) * placeRadius; + } + centers.push_back(center); + } + return centers; +} + +// Convert an EditableSubMesh's triangle soup into a Manifold solid. Position-only +// (Manifold does its own vertex welding by geometric position), which is all the +// boolean needs — attributes are re-derived after by nearest-source lookup. +manifold::Manifold toManifold(const EditableSubMesh& sub) +{ + manifold::MeshGL m; + m.numProp = 3; + m.vertProperties.reserve(sub.vertices.size() * 3); + for (const EditableVertex& v : sub.vertices) { + m.vertProperties.push_back(v.position.x); + m.vertProperties.push_back(v.position.y); + m.vertProperties.push_back(v.position.z); + } + m.triVerts.reserve(sub.triangles.size() * 3); + for (const EditableTriangle& t : sub.triangles) { + m.triVerts.push_back(t.indices[0]); + m.triVerts.push_back(t.indices[1]); + m.triVerts.push_back(t.indices[2]); + } + return manifold::Manifold(m); +} + +// Rebuild an EditableSubMesh from a Manifold result, re-deriving per-vertex +// attributes (normal/uv/colour/bone weights) from the ORIGINAL sub by nearest +// source vertex — so verts the boolean left untouched keep their exact data and +// newly-created socket-wall verts inherit their closest neighbour's attributes. +void fromManifold(const manifold::Manifold& man, const EditableSubMesh& original, + EditableSubMesh& out) +{ + manifold::MeshGL result = man.GetMeshGL(); + out.vertices.clear(); + out.triangles.clear(); + out.vertices.reserve(result.NumVert()); + + // Brute-force nearest source vertex (part vertex counts are small — a few + // thousand at most — and this runs once per socket cut). + auto nearestSource = [&](const Ogre::Vector3& p) -> const EditableVertex* { + const EditableVertex* best = nullptr; + float bestD = std::numeric_limits::max(); + for (const EditableVertex& sv : original.vertices) { + const float d = sv.position.squaredDistance(p); + if (d < bestD) { bestD = d; best = &sv; } + } + return best; + }; + + const uint32_t stride = result.numProp; + for (uint32_t i = 0; i < result.NumVert(); ++i) { + EditableVertex v; + v.position = Ogre::Vector3(result.vertProperties[i * stride + 0], + result.vertProperties[i * stride + 1], + result.vertProperties[i * stride + 2]); + if (const EditableVertex* src = nearestSource(v.position)) { + EditableVertex copy = *src; + copy.position = v.position; // keep the boolean's exact position + out.vertices.push_back(copy); + } else { + out.vertices.push_back(v); + } + } + for (size_t i = 0; i + 2 < result.triVerts.size(); i += 3) { + EditableTriangle t; + t.indices[0] = result.triVerts[i + 0]; + t.indices[1] = result.triVerts[i + 1]; + t.indices[2] = result.triVerts[i + 2]; + out.triangles.push_back(t); + } + out.materialName = original.materialName; +} + +// Cut real cylindrical socket cavities into `part` — one per peg center — via a +// robust mesh boolean. Each cutter is a cylinder of radius `r`, length `depth`, +// axis `-axis` (into the part), starting slightly proud of the seam so it fully +// overlaps the solid. Falls back to leaving `part` untouched if the boolean +// throws (degenerate input) — the male peg still guides assembly. +void subtractSockets(EditableSubMesh& part, const std::vector& centers, + const Ogre::Vector3& axis, float r, float depth, int segments) +{ + if (centers.empty() || part.triangles.empty()) + return; + try { + manifold::Manifold solid = toManifold(part); + if (solid.IsEmpty()) + return; + const Ogre::Vector3 unit = axis.normalisedCopy(); + // Manifold::Cylinder is built along +Z from the origin; rotate/translate + // each cutter so its +Z maps to -unit (into the part) starting proud of + // the seam. We approximate the transform with Manifold's own helpers by + // building the cylinder then applying a 4x4. + for (const Ogre::Vector3& c : centers) { + // A cylinder from the origin along +Z, height `depth`, radius r. + manifold::Manifold cutter = + manifold::Manifold::Cylinder(depth, r, r, segments, false); + // Orient +Z -> -unit. Build a rotation matrix from basis vectors. + const Ogre::Vector3 zdir = -unit; + Ogre::Vector3 upv = std::fabs(zdir.y) < 0.9f ? Ogre::Vector3::UNIT_Y + : Ogre::Vector3::UNIT_X; + Ogre::Vector3 xdir = upv.crossProduct(zdir).normalisedCopy(); + Ogre::Vector3 ydir = zdir.crossProduct(xdir).normalisedCopy(); + // Cutter starts barely proud of the seam (along +unit) so it fully + // spans into the part along -unit. + const Ogre::Vector3 base = c + unit * 0.001f; + // Column-major 3x4 affine for Manifold::Transform (mat3x4). + manifold::mat3x4 tf; + tf[0] = manifold::vec3(xdir.x, xdir.y, xdir.z); + tf[1] = manifold::vec3(ydir.x, ydir.y, ydir.z); + tf[2] = manifold::vec3(zdir.x, zdir.y, zdir.z); + tf[3] = manifold::vec3(base.x, base.y, base.z); + cutter = cutter.Transform(tf); + solid = solid - cutter; + } + if (solid.IsEmpty()) + return; + EditableSubMesh cut; + fromManifold(solid, part, cut); + if (!cut.triangles.empty()) + part = std::move(cut); + } catch (const std::exception&) { + // Boolean failed on degenerate input — leave the part unchanged. + } +} + } // namespace SubMeshOps::PrintPrepResult @@ -736,6 +961,15 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, out.partNames = partNames; out.partNames.resize(subMeshes.size()); + // Male pegs and socket-mouth collars are collected into two dedicated + // submeshes so they render in distinct colours (green male / red female) — + // the connector materials carry those colours (PartOpsMesh binds them). The + // real socket CAVITY is still cut into the mating part via the boolean below; + // the red collar is just a visible mouth indicator. + EditableSubMesh malePegs, socketCollars; + malePegs.materialName = "connector_male"; + socketCollars.materialName = "connector_socket"; + // Close each part's OPEN cut face first (a split leaves it hollow) so every // part is a watertight printable solid and the pegs attach to a real // surface. Boundary planes are still estimated from the ORIGINAL (uncapped) @@ -758,13 +992,28 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, rec.partA = a; rec.partB = b; rec.nameA = nameOf(a); rec.nameB = nameOf(b); - const BoundaryPlane plane = estimateBoundaryPlane({ subMeshes[a] }, { subMeshes[b] }); + BoundaryPlane plane = estimateBoundaryPlane({ subMeshes[a] }, { subMeshes[b] }); if (!plane.stable) { rec.reason = plane.reason.isEmpty() ? QStringLiteral("no stable shared boundary") : plane.reason; out.boundaries.push_back(rec); continue; } + // The best-fit normal's SIGN is arbitrary (an eigenvector), but the + // male peg extrudes along +normal — so orient it from the MALE part + // (A) toward the FEMALE part (B), using their body centroids, or the + // peg would protrude into the wrong part (CodeRabbit/Codex). + { + Ogre::Vector3 cA = Ogre::Vector3::ZERO, cB = Ogre::Vector3::ZERO; + size_t na = 0, nb = 0; + for (const auto& sm : { subMeshes[a] }) for (const auto& v : sm.vertices) { cA += v.position; ++na; } + for (const auto& sm : { subMeshes[b] }) for (const auto& v : sm.vertices) { cB += v.position; ++nb; } + if (na && nb) { + cA /= float(na); cB /= float(nb); + if (plane.normal.dotProduct(cB - cA) < 0.0f) + plane.normal = -plane.normal; + } + } // Adapt the peg size to THIS boundary so it always fits, regardless // of the model's unit scale (the issue's fixed radius=1.5 is 80% of a @@ -782,16 +1031,38 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, boundaryOpts.clearance *= scale; } - EditableSubMesh male, socket; - const int made = buildAlignmentPegs(plane, boundaryOpts, male, socket); + EditableSubMesh male, socketUnused; + const int made = buildAlignmentPegs(plane, boundaryOpts, male, socketUnused); if (made <= 0) { rec.reason = QStringLiteral("boundary too small for a peg"); out.boundaries.push_back(rec); continue; } - // Merge the male peg into partA and the socket into partB. - appendGeometry(out.subMeshes[a], male); - appendGeometry(out.subMeshes[b], socket); + // Collect the male peg into the shared GREEN connector submesh + // (protruding along +normal, toward B) so it renders distinctly. On a + // skinned mesh the peg inherits part A's nearest bone weights so it + // moves with that part instead of collapsing to the skeleton origin. + inheritNearestBoneWeights(male, subMeshes[a]); + appendGeometry(malePegs, male); + // Cut a real cylindrical SOCKET CAVITY into partB for each peg via a + // robust mesh boolean (Manifold), so the male peg actually inserts — + // not a solid cylinder added as fake geometry. The socket is the peg + // + clearance, sunk slightly behind the seam so it fully overlaps B. + const std::vector pegCenters = + pegRingCenters(plane, boundaryOpts, made); + const float socketR = boundaryOpts.pegRadius + boundaryOpts.clearance; + subtractSockets(out.subMeshes[b], pegCenters, plane.normal, socketR, + boundaryOpts.pegDepth + boundaryOpts.clearance, + boundaryOpts.radialSegments); + // A shallow RED collar ring at each socket mouth marks the female + // side visually (the cavity itself is a hole and can't be coloured). + // Built into a temp so it can inherit part B's bone weights. + EditableSubMesh collars; + for (const Ogre::Vector3& pc : pegCenters) + appendSocketCollar(collars, pc, plane.normal, socketR, + boundaryOpts.radialSegments); + inheritNearestBoneWeights(collars, subMeshes[b]); + appendGeometry(socketCollars, collars); rec.pegged = true; rec.pegCount = made; out.boundaries.push_back(rec); @@ -800,6 +1071,17 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, } } + // Append the two connector submeshes (if any pegs were placed) so they get + // their own material bindings + Scene-tree rows. + if (!malePegs.triangles.empty()) { + out.subMeshes.push_back(std::move(malePegs)); + out.partNames.push_back(QStringLiteral("connector_male")); + } + if (!socketCollars.triangles.empty()) { + out.subMeshes.push_back(std::move(socketCollars)); + out.partNames.push_back(QStringLiteral("connector_socket")); + } + out.ok = true; if (out.peggedBoundaries == 0) out.error = QStringLiteral("no stable boundary found — no pegs added"); diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index 393829a4..7552ae7b 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -465,10 +465,20 @@ TEST(SubMeshOpsTest, PreparePrintPegsAddsMaleAndSocket) ASSERT_TRUE(r.ok) << r.error.toStdString(); EXPECT_EQ(r.peggedBoundaries, 1); EXPECT_GT(r.totalPegs, 0); - ASSERT_EQ(r.subMeshes.size(), 2u); - // Each part gained connector geometry (more verts than it started with). - EXPECT_GT(r.subMeshes[0].vertices.size(), aVerts0); - EXPECT_GT(r.subMeshes[1].vertices.size(), bVerts0); + (void)aVerts0; + // The two input parts stay first; the male peg + socket collar are appended + // as their own coloured connector submeshes (green/red). + ASSERT_EQ(r.subMeshes.size(), 4u); + ASSERT_EQ(r.partNames.size(), 4u); + EXPECT_EQ(r.subMeshes[2].materialName, "connector_male"); + EXPECT_EQ(r.subMeshes[3].materialName, "connector_socket"); + EXPECT_EQ(r.partNames[2].toStdString(), "connector_male"); + EXPECT_EQ(r.partNames[3].toStdString(), "connector_socket"); + EXPECT_FALSE(r.subMeshes[2].triangles.empty()); // male peg has geometry + EXPECT_FALSE(r.subMeshes[3].triangles.empty()); // socket collar has geometry + // Part B (the female side) had a real socket cavity cut into it, so its + // vertex count changed from the boolean. + EXPECT_NE(r.subMeshes[1].vertices.size(), bVerts0); // The boundary report is populated with both part names. ASSERT_EQ(r.boundaries.size(), 1u); EXPECT_TRUE(r.boundaries[0].pegged); @@ -476,6 +486,42 @@ TEST(SubMeshOpsTest, PreparePrintPegsAddsMaleAndSocket) EXPECT_EQ(r.boundaries[0].nameB.toStdString(), "left_leg"); } +TEST(SubMeshOpsTest, PreparePrintPegsConnectorsInheritBoneWeights) +{ + // A SKINNED two-part input: every part vertex is weighted to a bone. The + // connector submeshes (peg + collar) start weightless, so preparePrintPegs + // must inherit the nearest part vertex's weights or they'd collapse to the + // skeleton origin under animation. + EditableSubMesh a, b; + twoPartsWithSeam(a, b); + for (auto* part : {&a, &b}) { + const unsigned short bone = (part == &a) ? 3 : 7; + for (auto& v : part->vertices) { + EditableBoneAssignment ba; ba.boneIndex = bone; ba.weight = 1.0f; + v.boneAssignments.push_back(ba); + } + } + SubMeshOps::PegOptions opts; + opts.pegRadius = 0.4f; opts.pegDepth = 1.0f; opts.maxPegsPerBoundary = 3; + auto r = SubMeshOps::preparePrintPegs({a, b}, opts, {"torso", "left_leg"}); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + ASSERT_EQ(r.subMeshes.size(), 4u); + // Every connector vertex (male=idx 2, socket collar=idx 3) is now weighted. + for (size_t s : {size_t(2), size_t(3)}) { + ASSERT_FALSE(r.subMeshes[s].vertices.empty()); + for (const auto& v : r.subMeshes[s].vertices) + EXPECT_FALSE(v.boneAssignments.empty()) + << "connector submesh " << s << " has a weightless vertex"; + } + // Male peg inherits part A's bone (3); collar inherits part B's bone (7). + EXPECT_EQ(r.subMeshes[2].vertices[0].boneAssignments[0].boneIndex, 3); + EXPECT_EQ(r.subMeshes[3].vertices[0].boneAssignments[0].boneIndex, 7); + // The socket CAVITY cut into part B keeps part B's weights too (Manifold + // carry-over via nearest-source): no weightless vertex in part B. + for (const auto& v : r.subMeshes[1].vertices) + EXPECT_FALSE(v.boneAssignments.empty()) << "socket-cut part B vertex lost weights"; +} + TEST(SubMeshOpsTest, PreparePrintPegsRejectsTinyBoundary) { // Two parts that do NOT share enough coincident verts (< 8) → no stable diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7f3d9e87..5761f2ae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -562,6 +562,7 @@ ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/MeshReconstructorTexKeys.cpp Qt::QuickWidgets Qt::QuickControls2 meshoptimizer + manifold xatlas qtmesh_sodium qtmesh_updater From a40fb259979b4edd42bee31c2ac7bcdc776caae7 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 27 Jul 2026 23:15:42 -0400 Subject: [PATCH 04/12] chore(#863): emit mesh.parts.print_pegs breadcrumb from the operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the print-prep telemetry into PartOpsMesh::addPrintPegsToEntity so every caller (CLI segment --print-pegs, MCP prepare_print_split, and the undo command) records mesh.parts.print_pegs with boundary/peg/capped/warning counts — not only the undo command's redo() (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PartOpsMesh.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index 530e1f8b..e8828922 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -1,6 +1,7 @@ #include "PartOpsMesh.h" #include "EditableMesh.h" +#include "SentryReporter.h" #include #include @@ -233,5 +234,13 @@ PartOpsMesh::addPrintPegsToEntity(Ogre::Entity* entity, const SubMeshOps::PegOpt out.partNames = std::move(prep.partNames); out.peggedBoundaries = prep.peggedBoundaries; out.totalPegs = prep.totalPegs; + + // Telemetry for the operation itself so EVERY caller (CLI/MCP/command) gets a + // breadcrumb, not just the undo command's redo() (CodeRabbit). + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.print_pegs"), + QStringLiteral("boundaries=%1 pegs=%2 capped=%3 warnings=%4") + .arg(out.peggedBoundaries).arg(out.totalPegs) + .arg(prep.cappedParts) + .arg(static_cast(out.warnings.size()))); return out; } From bc599888dd8b734e677ab8f542563e7fcac6571e Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Jul 2026 14:37:11 -0400 Subject: [PATCH 05/12] feat(#863): merge print connectors into their own parts (per user request) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each alignment connector now belongs to the part it attaches to, so every part stays ONE self-contained printable mesh in its own material — no separate connector submeshes: - Male peg is appended INTO its source part A (renders in A's material). - Female socket cavity is cut into part B (Manifold boolean, unchanged) and its raised collar ring is appended INTO part B. - Dropped the shared connector_male/connector_socket submeshes and the green/red PartOpsMesh::ensureConnectorMaterials (no longer needed). Connector geometry still inherits its part's nearest bone weights so pegs move with their part on skinned meshes; the boolean cavity walls inherit via the Manifold nearest-source re-derivation. Tests updated: preparePrintPegs now yields exactly the input parts (2, not 4), each keeps its own material, every vertex (incl. merged peg/collar/cavity verts) is weighted to its part's bone; the GL round-trip asserts the submesh count is unchanged and the vertex count grows. All green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- ...LIPipeline_cmdsplitparts_coverage_test.cpp | 31 +++++++---- src/PartOpsMesh.cpp | 32 ------------ src/SubMeshOps.cpp | 42 +++++---------- src/SubMeshOps_test.cpp | 51 +++++++++---------- 5 files changed, 61 insertions(+), 97 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 34f6b28f..86744f65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg geometry; the female SOCKET is a REAL cavity cut via the Manifold mesh-boolean, not solid geometry). **`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) — print-split prep with alignment pegs**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow — bad for printing, and a peg needs a solid face): it finds boundary edges (a directed edge whose reverse is absent), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Applied inside print-prep (always, before pegs) and as an opt-in explode toggle ("Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries` param), so an exploded/pegged part is a watertight solid. `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam (oriented from the MALE part A toward the FEMALE part B via body centroids — the eigenvector sign is arbitrary but the peg extrudes along +normal), and where stable builds matching cylindrical pegs via `buildAlignmentPegs`. The MALE peg is collected into a shared **`connector_male`** submesh (rendered GREEN); the female SOCKET is a **real cylindrical CAVITY** cut into part B via the **Manifold** mesh-boolean (`subtractSockets` — EditableSubMesh→`manifold::Manifold`, subtract one `Cylinder` per peg center, convert back re-deriving per-vertex attributes by nearest-source lookup; falls soft to leaving the part untouched if the boolean throws), plus a shallow **`connector_socket`** collar ring at each socket mouth (rendered RED) so the female side is visible. Both connector submeshes are appended after the parts with their own names/materials. **Manifold** (elalish/manifold, MIT, robust mesh CSG) is vendored via FetchContent (v3.0.1, configured lean — no tests/exports/parallel/bindings) alongside meshoptimizer/xatlas; `PartOpsMesh::ensureConnectorMaterials` creates the green/red self-lit materials on demand. Peg-ring centers are reproduced from `buildAlignmentPegs`'s placement via the shared `pegRingCenters` helper so the socket cutters line up 1:1 with the male pegs. Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), 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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg geometry; the female SOCKET is a REAL cavity cut via the Manifold mesh-boolean, not solid geometry; each connector is merged INTO its own part so every part stays one self-contained mesh in its own material). **`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) — print-split prep with alignment pegs**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow — bad for printing, and a peg needs a solid face): it finds boundary edges (a directed edge whose reverse is absent), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Applied inside print-prep (always, before pegs) and as an opt-in explode toggle ("Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries` param), so an exploded/pegged part is a watertight solid. `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam (oriented from the MALE part A toward the FEMALE part B via body centroids — the eigenvector sign is arbitrary but the peg extrudes along +normal), and where stable builds matching cylindrical pegs via `buildAlignmentPegs`. Each connector is merged **INTO its own part** so every part stays ONE self-contained printable mesh in its own material (no separate connector submeshes): the MALE peg is appended into its source part A; the female SOCKET is a **real cylindrical CAVITY** cut into part B via the **Manifold** mesh-boolean (`subtractSockets` — EditableSubMesh→`manifold::Manifold`, subtract one `Cylinder` per peg center, convert back re-deriving per-vertex attributes by nearest-source lookup; falls soft to leaving the part untouched if the boolean throws), plus a shallow raised collar ring at each socket mouth appended into part B. **Manifold** (elalish/manifold, MIT, robust mesh CSG) is vendored via FetchContent (v3.0.1, configured lean — no tests/exports/parallel/bindings) alongside meshoptimizer/xatlas. Connector geometry inherits its part's nearest bone weights (`inheritNearestBoneWeights`) so pegs move with their part on a skinned mesh; the boolean cavity walls inherit via `fromManifold`'s nearest-source. Peg-ring centers are reproduced from `buildAlignmentPegs`'s placement via the shared `pegRingCenters` helper so the socket cutters line up 1:1 with the male pegs. (An earlier cut rendered the male pegs GREEN / female collars RED as two dedicated connector submeshes; changed per user request so each connector belongs to its part.) Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), F docs. - **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/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp index fe3694d4..369c3047 100644 --- a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -267,6 +267,12 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, AddPrintPegsCommandRedoUndoRoundTri const std::string entityName = e->getName(); const unsigned short subMeshesBefore = e->getMesh()->getNumSubMeshes(); ASSERT_GT(subMeshesBefore, 1u) << "peg command needs a multi-part mesh"; + size_t vertsBefore = 0; + { + EditableMesh em; + if (em.loadFromEntity(e)) + for (const auto& sm : em.subMeshes()) vertsBefore += sm.vertices.size(); + } // 3) redo(): build + swap in the pegged mesh. SubMeshOps::PegOptions opts; // defaults; auto-fits the boundary @@ -280,17 +286,22 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, AddPrintPegsCommandRedoUndoRoundTri pegged = cand; ASSERT_NE(pegged, nullptr) << "pegged entity not found after redo"; if (cmd.peggedBoundaries() > 0) { - // The male + socket connector submeshes were appended (2 extra parts). - EXPECT_GT(pegged->getMesh()->getNumSubMeshes(), subMeshesBefore) - << "pegging should append connector submeshes"; + // Connectors are merged INTO their parts, so the submesh count is + // UNCHANGED (no separate connector submeshes) — the pegs live inside the + // existing part submeshes, which gain geometry. + EXPECT_EQ(pegged->getMesh()->getNumSubMeshes(), subMeshesBefore) + << "pegging must not change the part/submesh count"; EXPECT_GT(cmd.totalPegs(), 0); - // At least one submesh carries a connector material. - bool hasConnector = false; - for (unsigned short i = 0; i < pegged->getNumSubEntities(); ++i) { - const std::string m = pegged->getSubEntity(i)->getMaterialName(); - if (m == "connector_male" || m == "connector_socket") { hasConnector = true; break; } - } - EXPECT_TRUE(hasConnector) << "no connector_male/connector_socket submesh after pegging"; + // The pegged mesh has MORE vertices than the pre-peg mesh (peg + collar + + // socket-cavity geometry merged into the parts). + auto vertsOf = [](Ogre::Entity* e) { + EditableMesh em; size_t n = 0; + if (em.loadFromEntity(e)) + for (const auto& sm : em.subMeshes()) n += sm.vertices.size(); + return n; + }; + EXPECT_GT(vertsOf(pegged), vertsBefore) + << "merged pegs/collars/cavities should add vertices to the parts"; } // 4) undo(): the pre-peg mesh (same submesh count) is restored. diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index e8828922..c2f79b15 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -7,38 +7,9 @@ #include #include #include -#include -#include -#include -#include #include -namespace { -// Create (once) a solid-coloured, self-lit material so the print connectors are -// unmistakable: green = male peg, red = female socket collar. Idempotent. -void ensureConnectorMaterial(const std::string& name, const Ogre::ColourValue& c) -{ - auto& mm = Ogre::MaterialManager::getSingleton(); - if (mm.resourceExists(name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME)) - return; - Ogre::MaterialPtr mat = mm.create(name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - Ogre::Pass* pass = mat->getTechnique(0)->getPass(0); - pass->setDiffuse(c); - pass->setAmbient(c); - pass->setSelfIllumination(c * 0.6f); // glow a bit so it reads even unlit - pass->setSpecular(Ogre::ColourValue(0.2f, 0.2f, 0.2f, 1.0f)); - pass->setShininess(16.0f); - mat->compile(); -} - -void ensureConnectorMaterials() -{ - ensureConnectorMaterial("connector_male", Ogre::ColourValue(0.15f, 0.80f, 0.20f, 1.0f)); - ensureConnectorMaterial("connector_socket", Ogre::ColourValue(0.85f, 0.15f, 0.15f, 1.0f)); -} -} // namespace - bool PartOpsMesh::readSubMeshes(Ogre::Entity* entity, std::vector& outSubMeshes) { @@ -78,9 +49,6 @@ Ogre::MeshPtr PartOpsMesh::buildMesh(const std::vector& subMesh { if (subMeshes.empty()) return Ogre::MeshPtr(); - // Print connectors reference the green/red connector_* materials — make sure - // they exist (a no-op when the mesh has none). - ensureConnectorMaterials(); // EditableMesh::createNewMesh is the canonical build-a-fresh-mesh path // (createSubMesh per part + buildSubMeshBuffers + normals + bounds). We // borrow it by seeding an EditableMesh's submesh vector directly. diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index ba6d0b1f..1f72acec 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -961,14 +961,10 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, out.partNames = partNames; out.partNames.resize(subMeshes.size()); - // Male pegs and socket-mouth collars are collected into two dedicated - // submeshes so they render in distinct colours (green male / red female) — - // the connector materials carry those colours (PartOpsMesh binds them). The - // real socket CAVITY is still cut into the mating part via the boolean below; - // the red collar is just a visible mouth indicator. - EditableSubMesh malePegs, socketCollars; - malePegs.materialName = "connector_male"; - socketCollars.materialName = "connector_socket"; + // Each connector is merged directly INTO the part it belongs to (male peg → + // its source part, female socket cavity + collar → the mating part) so every + // part stays ONE self-contained printable mesh in its own material — no + // separate connector submeshes. // Close each part's OPEN cut face first (a split leaves it hollow) so every // part is a watertight printable solid and the pegs attach to a real @@ -1038,12 +1034,13 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, out.boundaries.push_back(rec); continue; } - // Collect the male peg into the shared GREEN connector submesh - // (protruding along +normal, toward B) so it renders distinctly. On a - // skinned mesh the peg inherits part A's nearest bone weights so it - // moves with that part instead of collapsing to the skeleton origin. + // Merge the male peg directly INTO its source part (A) so each part + // is one self-contained printable object that carries its own peg — + // it renders in the part's own material, not a separate connector + // submesh. On a skinned mesh the peg inherits part A's nearest bone + // weights so it moves with that part (not the skeleton origin). inheritNearestBoneWeights(male, subMeshes[a]); - appendGeometry(malePegs, male); + appendGeometry(out.subMeshes[a], male); // Cut a real cylindrical SOCKET CAVITY into partB for each peg via a // robust mesh boolean (Manifold), so the male peg actually inserts — // not a solid cylinder added as fake geometry. The socket is the peg @@ -1054,15 +1051,15 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, subtractSockets(out.subMeshes[b], pegCenters, plane.normal, socketR, boundaryOpts.pegDepth + boundaryOpts.clearance, boundaryOpts.radialSegments); - // A shallow RED collar ring at each socket mouth marks the female - // side visually (the cavity itself is a hole and can't be coloured). - // Built into a temp so it can inherit part B's bone weights. + // A shallow collar ring at each socket mouth (a raised lip around the + // bore). Merged INTO part B so the female side is one solid too; + // inherits part B's bone weights. Built into a temp first for that. EditableSubMesh collars; for (const Ogre::Vector3& pc : pegCenters) appendSocketCollar(collars, pc, plane.normal, socketR, boundaryOpts.radialSegments); inheritNearestBoneWeights(collars, subMeshes[b]); - appendGeometry(socketCollars, collars); + appendGeometry(out.subMeshes[b], collars); rec.pegged = true; rec.pegCount = made; out.boundaries.push_back(rec); @@ -1071,17 +1068,6 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, } } - // Append the two connector submeshes (if any pegs were placed) so they get - // their own material bindings + Scene-tree rows. - if (!malePegs.triangles.empty()) { - out.subMeshes.push_back(std::move(malePegs)); - out.partNames.push_back(QStringLiteral("connector_male")); - } - if (!socketCollars.triangles.empty()) { - out.subMeshes.push_back(std::move(socketCollars)); - out.partNames.push_back(QStringLiteral("connector_socket")); - } - out.ok = true; if (out.peggedBoundaries == 0) out.error = QStringLiteral("no stable boundary found — no pegs added"); diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index 7552ae7b..c109b3f6 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -465,19 +465,18 @@ TEST(SubMeshOpsTest, PreparePrintPegsAddsMaleAndSocket) ASSERT_TRUE(r.ok) << r.error.toStdString(); EXPECT_EQ(r.peggedBoundaries, 1); EXPECT_GT(r.totalPegs, 0); - (void)aVerts0; - // The two input parts stay first; the male peg + socket collar are appended - // as their own coloured connector submeshes (green/red). - ASSERT_EQ(r.subMeshes.size(), 4u); - ASSERT_EQ(r.partNames.size(), 4u); - EXPECT_EQ(r.subMeshes[2].materialName, "connector_male"); - EXPECT_EQ(r.subMeshes[3].materialName, "connector_socket"); - EXPECT_EQ(r.partNames[2].toStdString(), "connector_male"); - EXPECT_EQ(r.partNames[3].toStdString(), "connector_socket"); - EXPECT_FALSE(r.subMeshes[2].triangles.empty()); // male peg has geometry - EXPECT_FALSE(r.subMeshes[3].triangles.empty()); // socket collar has geometry - // Part B (the female side) had a real socket cavity cut into it, so its - // vertex count changed from the boolean. + // Each connector is merged INTO its part — NO separate connector submeshes. + // The result keeps exactly the two input parts, each in its own material. + ASSERT_EQ(r.subMeshes.size(), 2u); + ASSERT_EQ(r.partNames.size(), 2u); + EXPECT_EQ(r.subMeshes[0].materialName, "Body"); + EXPECT_EQ(r.subMeshes[1].materialName, "Body"); + EXPECT_EQ(r.partNames[0].toStdString(), "torso"); + EXPECT_EQ(r.partNames[1].toStdString(), "left_leg"); + // Part A (male side) gained the peg's extra geometry. + EXPECT_GT(r.subMeshes[0].vertices.size(), aVerts0); + // Part B (female side) had a real socket cavity cut into it AND a collar + // merged in, so its vertex count changed from the input. EXPECT_NE(r.subMeshes[1].vertices.size(), bVerts0); // The boundary report is populated with both part names. ASSERT_EQ(r.boundaries.size(), 1u); @@ -489,9 +488,9 @@ TEST(SubMeshOpsTest, PreparePrintPegsAddsMaleAndSocket) TEST(SubMeshOpsTest, PreparePrintPegsConnectorsInheritBoneWeights) { // A SKINNED two-part input: every part vertex is weighted to a bone. The - // connector submeshes (peg + collar) start weightless, so preparePrintPegs - // must inherit the nearest part vertex's weights or they'd collapse to the - // skeleton origin under animation. + // connector geometry (peg + collar + boolean cavity walls) starts weightless + // and is merged INTO its part, so preparePrintPegs must inherit the nearest + // part vertex's weights or those verts collapse to the skeleton origin. EditableSubMesh a, b; twoPartsWithSeam(a, b); for (auto* part : {&a, &b}) { @@ -505,21 +504,21 @@ TEST(SubMeshOpsTest, PreparePrintPegsConnectorsInheritBoneWeights) opts.pegRadius = 0.4f; opts.pegDepth = 1.0f; opts.maxPegsPerBoundary = 3; auto r = SubMeshOps::preparePrintPegs({a, b}, opts, {"torso", "left_leg"}); ASSERT_TRUE(r.ok) << r.error.toStdString(); - ASSERT_EQ(r.subMeshes.size(), 4u); - // Every connector vertex (male=idx 2, socket collar=idx 3) is now weighted. - for (size_t s : {size_t(2), size_t(3)}) { + ASSERT_EQ(r.subMeshes.size(), 2u); + // Connectors merged into the parts: EVERY vertex of both parts is weighted + // (the male peg + collar + socket-cavity walls all inherited a part bone). + for (size_t s : {size_t(0), size_t(1)}) { ASSERT_FALSE(r.subMeshes[s].vertices.empty()); for (const auto& v : r.subMeshes[s].vertices) EXPECT_FALSE(v.boneAssignments.empty()) - << "connector submesh " << s << " has a weightless vertex"; + << "part submesh " << s << " has a weightless (connector) vertex"; } - // Male peg inherits part A's bone (3); collar inherits part B's bone (7). - EXPECT_EQ(r.subMeshes[2].vertices[0].boneAssignments[0].boneIndex, 3); - EXPECT_EQ(r.subMeshes[3].vertices[0].boneAssignments[0].boneIndex, 7); - // The socket CAVITY cut into part B keeps part B's weights too (Manifold - // carry-over via nearest-source): no weightless vertex in part B. + // Part A's verts stay on bone 3 (its own bone + the merged peg's inherited + // bone); part B's verts stay on bone 7. + for (const auto& v : r.subMeshes[0].vertices) + EXPECT_EQ(v.boneAssignments[0].boneIndex, 3) << "part A vertex not on bone 3"; for (const auto& v : r.subMeshes[1].vertices) - EXPECT_FALSE(v.boneAssignments.empty()) << "socket-cut part B vertex lost weights"; + EXPECT_EQ(v.boneAssignments[0].boneIndex, 7) << "part B vertex not on bone 7"; } TEST(SubMeshOpsTest, PreparePrintPegsRejectsTinyBoundary) From 9b776138b877c933b443ce1d8ecbeedb00e62ef0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Jul 2026 14:54:15 -0400 Subject: [PATCH 06/12] fix(#863): cap ALL boundary loops watertight (close the gap on every joint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capOpenBoundaries had two defects that left split parts open at some joints: 1. Single-successor walk: a rim vertex with more than one outgoing boundary edge (figure-eight / pinched cut, or two rim loops sharing a vertex — common at shoulders/hips) dropped the extra edges, so only one loop got capped. Now every boundary edge is stored in a per-vertex successor LIST and consumed exactly once, so all rim loops are walked and filled. 2. Wrong fan winding: the cap used a global centroid-normal heuristic to pick a single orientation for the whole part, which flips the wrong end of a multi-loop part (e.g. a tube) and leaves that rim's edges uncancelled. Now each cap triangle is wound to REVERSE its boundary edge directly (centre, b, a) for edge a→b — guaranteed watertight for any loop shape. New test CapOpenBoundariesClosesBothEndsOfATube: an open tube (two rim loops) now caps BOTH ends and reports 0 boundary edges (watertight). All cap/peg tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/SubMeshOps.cpp | 100 ++++++++++++++++++++++++---------------- src/SubMeshOps_test.cpp | 42 +++++++++++++++++ 2 files changed, 101 insertions(+), 41 deletions(-) diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index 1f72acec..b672d1ad 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -443,68 +443,86 @@ int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) dirCount[key(t.indices[1], t.indices[2])]++; dirCount[key(t.indices[2], t.indices[0])]++; } - // A directed edge a→b is a boundary edge when b→a is absent. Build the - // successor map next[a] = b over boundary edges to walk the loops. - std::unordered_map next; + // A directed edge a→b is a boundary edge when b→a is absent. A rim vertex can + // have MORE than one outgoing boundary edge (a figure-eight / pinched cut, or + // two separate rim loops touching a shared vertex — common at shoulders/hips), + // so keep a LIST of successors per vertex and CONSUME them as we walk. A + // single-successor map silently drops the extra edges and leaves those loops + // uncapped (the "gap not closed on all joints" bug). + std::unordered_map> succ; + size_t boundaryEdges = 0; for (const auto& kv : dirCount) { const unsigned int a = static_cast(kv.first >> 32); const unsigned int b = static_cast(kv.first & 0xffffffff); - if (dirCount.find(key(b, a)) == dirCount.end()) - next[a] = b; // boundary edge a→b (the interior is to its left) + if (dirCount.find(key(b, a)) == dirCount.end()) { + succ[a].push_back(b); // boundary edge a→b (interior on its left) + ++boundaryEdges; + } } - if (next.empty()) + if (boundaryEdges == 0) return 0; // already closed - // Part centroid — used to orient each cap OUTWARD. - Ogre::Vector3 partC = Ogre::Vector3::ZERO; - for (const auto& v : sub.vertices) partC += v.position; - partC /= static_cast(sub.vertices.size()); - - // 2) Walk each boundary loop from an unvisited start, following next[]. + // 2) Walk each boundary loop by consuming edges from `succ`. Every boundary + // edge is used exactly once, so ALL rim loops get capped — not just the + // first one reachable from each vertex. + auto popSucc = [&](unsigned int a, bool& ok) -> unsigned int { + auto it = succ.find(a); + if (it == succ.end() || it->second.empty()) { ok = false; return 0; } + unsigned int b = it->second.back(); + it->second.pop_back(); + if (it->second.empty()) succ.erase(it); + ok = true; + return b; + }; int caps = 0; - std::unordered_map visited; - for (const auto& seed : next) { - const unsigned int start = seed.first; - if (visited.count(start)) - continue; - std::vector loop; + size_t consumed = 0; + while (consumed < boundaryEdges) { + // Find any vertex that still has an unused outgoing boundary edge. + unsigned int start = 0; bool found = false; + for (const auto& kv : succ) { if (!kv.second.empty()) { start = kv.first; found = true; break; } } + if (!found) + break; + // Record the actual DIRECTED boundary edges (a→b) we consume, in order. + std::vector> edges; + std::vector loopVerts; unsigned int cur = start; - while (next.count(cur) && !visited.count(cur)) { - visited[cur] = true; - loop.push_back(cur); - cur = next[cur]; - if (cur == start) break; // closed + // Follow successors, consuming each edge, until we return to start or hit + // a vertex with no remaining successor (open chain — still fan it). + for (;;) { + bool ok = false; + unsigned int nxt = popSucc(cur, ok); + if (!ok) break; + ++consumed; + edges.emplace_back(cur, nxt); + loopVerts.push_back(cur); + cur = nxt; + if (cur == start) break; // closed loop } - if (loop.size() < 3) + if (edges.size() < 3) continue; // 3) Centroid-fan fill. New centre vertex copies a rim vertex's // attributes (material/uv space) with the averaged position. Ogre::Vector3 c = Ogre::Vector3::ZERO; - for (unsigned int vi : loop) c += sub.vertices[vi].position; - c /= static_cast(loop.size()); - EditableVertex centre = sub.vertices[loop[0]]; + for (unsigned int vi : loopVerts) c += sub.vertices[vi].position; + c /= static_cast(loopVerts.size()); + EditableVertex centre = sub.vertices[loopVerts[0]]; centre.position = c; centre.hasNormal = false; // recomputed after (or by createNewMesh) const unsigned int cIdx = static_cast(sub.vertices.size()); sub.vertices.push_back(centre); - // Winding: the boundary edge a→b has the part interior on its LEFT, so a - // fan triangle (centre, a, b) faces the SAME way as the missing cap. Test - // one triangle's normal against the outward direction (centre→partC) and - // flip all if it points inward. - const unsigned int a0 = loop[0], b0 = loop[1]; - const Ogre::Vector3 n0 = (sub.vertices[a0].position - c) - .crossProduct(sub.vertices[b0].position - c); - const bool flip = n0.dotProduct(c - partC) < 0.0f; // want normal away from partC - const size_t nEdges = loop.size(); - for (size_t i = 0; i < nEdges; ++i) { - const unsigned int a = loop[i]; - const unsigned int b = loop[(i + 1) % nEdges]; + // Winding — the ONLY watertight choice: a boundary edge a→b has the part + // interior on its LEFT, so the cap triangle must contain the REVERSE edge + // b→a to cancel it. Emit (centre, b, a) for every consumed edge. This is + // exact for any loop shape and both ends of a tube, unlike a global + // centroid-normal heuristic (which flips the wrong end and left the rim + // open — the "gap not closed on all joints" bug). + for (const auto& e : edges) { EditableTriangle t; t.indices[0] = cIdx; - t.indices[1] = flip ? b : a; - t.indices[2] = flip ? a : b; + t.indices[1] = e.second; // b + t.indices[2] = e.first; // a sub.triangles.push_back(t); } ++caps; diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index c109b3f6..b5aec90f 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -8,6 +8,8 @@ #include "MeshSegmenter.h" #include +#include +#include namespace { @@ -593,3 +595,43 @@ TEST(SubMeshOpsTest, CapOpenBoundariesNoOpWhenClosed) EXPECT_EQ(SubMeshOps::capOpenBoundaries(s), 0); EXPECT_EQ(s.triangles.size(), before); } + +// Count directed boundary edges (a→b with no b→a) — 0 means watertight. +static size_t boundaryEdgeCount(const EditableSubMesh& s) +{ + std::map,int> d; + for (const auto& t : s.triangles) { + d[{t.indices[0],t.indices[1]}]++; + d[{t.indices[1],t.indices[2]}]++; + d[{t.indices[2],t.indices[0]}]++; + } + size_t open = 0; + for (const auto& kv : d) + if (!d.count({kv.first.second, kv.first.first})) open += 1; + return open; +} + +TEST(SubMeshOpsTest, CapOpenBoundariesClosesBothEndsOfATube) +{ + // An open tube (a ring extruded along Y, NO end caps): TWO separate boundary + // loops. The old single-successor walk capped only one; the multi-successor + // walk must close BOTH → 0 boundary edges after, watertight. + EditableSubMesh s; s.materialName = "Tube"; + const int seg = 8; + auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); return v; }; + for (int i = 0; i < seg; ++i) { + const float a = 2.0f*float(M_PI)*float(i)/float(seg); + s.vertices.push_back(V(std::cos(a), 0.f, std::sin(a))); // bottom ring + s.vertices.push_back(V(std::cos(a), 2.f, std::sin(a))); // top ring + } + for (int i = 0; i < seg; ++i) { + const int j = (i+1)%seg; + const unsigned b0=2*i, t0=2*i+1, b1=2*j, t1=2*j+1; + addTri(s, b0, b1, t1); + addTri(s, b0, t1, t0); + } + ASSERT_GT(boundaryEdgeCount(s), 0u); // open at both ends + const int caps = SubMeshOps::capOpenBoundaries(s); + EXPECT_EQ(caps, 2) << "both tube ends must be capped"; + EXPECT_EQ(boundaryEdgeCount(s), 0u) << "tube must be watertight after capping"; +} From 1d0b0b9e958cfc5a231ab79f45163e988b1dd53b Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Jul 2026 15:03:59 -0400 Subject: [PATCH 07/12] fix(#863): bound socket depth to part thickness + recompute peg normals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dark "holes" on the split parts were oversized sockets, not open meshes (all parts verified watertight, 0 boundary edges). Two fixes: - Socket depth is now bounded to 35% of the THINNER mating part's extent along the peg axis, so a deep default peg (pegDepth=4) no longer bores clean through a thin torso/limb (the dark tunnel). Peg radius cap tightened 35%→30% of the boundary ring, and peg count is held to 1 (or 2 for a large ring) so a joint doesn't sprout multiple big pits. - The pegged mesh is now built with recomputeNormals=true: the Manifold boolean + cap fans create faces whose nearest-source normals point outward on a concave cavity wall → dark/black shading. Recomputing gives the connectors and cavity walls correct normals. Plain split still preserves authored normals (recomputeNormals defaults false; only the peg path opts in). Verified on Hip Hop Dancing.fbx via CLI: 6 parts, all watertight, sockets sized to part thickness. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/PartOpsMesh.cpp | 20 ++++++++++++++------ src/PartOpsMesh.h | 3 ++- src/SubMeshOps.cpp | 43 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index c2f79b15..2ad15543 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -45,7 +45,8 @@ bool PartOpsMesh::readSubMeshes(Ogre::Entity* entity, Ogre::MeshPtr PartOpsMesh::buildMesh(const std::vector& subMeshes, const std::string& baseName, const QString& skeletonName, - const std::vector& subMeshNames) + const std::vector& subMeshNames, + bool recomputeNormals) { if (subMeshes.empty()) return Ogre::MeshPtr(); @@ -54,10 +55,13 @@ Ogre::MeshPtr PartOpsMesh::buildMesh(const std::vector& subMesh // borrow it by seeding an EditableMesh's submesh vector directly. EditableMesh em; em.subMeshes() = subMeshes; - // recomputeNormals=false: SubMeshOps copied the source normals (incl. - // authored / hard-edge normals) verbatim, so recomputing would change the - // shading the split is meant to preserve (#859 review). - Ogre::MeshPtr mesh = em.createNewMesh(baseName, /*recomputeNormals=*/false); + // A plain SPLIT keeps recomputeNormals=false so the source normals (incl. + // authored / hard-edge normals) survive verbatim (#859 review). The PEG path + // passes true: the Manifold boolean + cap fans introduce new faces whose + // nearest-source normals point the wrong way for a concave cavity wall + // (dark/black shading — the "holes look wrong" symptom), so recomputing + // gives the connectors correct outward normals. + Ogre::MeshPtr mesh = em.createNewMesh(baseName, recomputeNormals); if (!mesh) return mesh; @@ -191,7 +195,11 @@ PartOpsMesh::addPrintPegsToEntity(Ogre::Entity* entity, const SubMeshOps::PegOpt QString skelName; if (entity->getMesh()->hasSkeleton()) skelName = QString::fromStdString(entity->getMesh()->getSkeletonName()); - Ogre::MeshPtr mesh = buildMesh(prep.subMeshes, baseName, skelName, prep.partNames); + // recomputeNormals=true: the peg/socket/cap geometry needs correct outward + // normals (nearest-source copy from the boolean gives concave-wall verts an + // outward normal → dark shading). + Ogre::MeshPtr mesh = buildMesh(prep.subMeshes, baseName, skelName, prep.partNames, + /*recomputeNormals=*/true); if (!mesh) { out.error = QStringLiteral("failed to build pegged mesh"); return out; diff --git a/src/PartOpsMesh.h b/src/PartOpsMesh.h index 8ecc5619..b011318c 100644 --- a/src/PartOpsMesh.h +++ b/src/PartOpsMesh.h @@ -54,7 +54,8 @@ class PartOpsMesh static Ogre::MeshPtr buildMesh(const std::vector& subMeshes, const std::string& baseName, const QString& skeletonName = QString(), - const std::vector& subMeshNames = {}); + const std::vector& subMeshNames = {}, + bool recomputeNormals = false); struct SplitOutcome { bool ok = false; diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index b672d1ad..79026598 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -1032,12 +1032,12 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, // Adapt the peg size to THIS boundary so it always fits, regardless // of the model's unit scale (the issue's fixed radius=1.5 is 80% of a // unit-normalised character's diagonal — a giant blob). A peg radius - // is capped at 35% of the boundary ring radius, and the socket + // is capped at 30% of the boundary ring radius, and the socket // clearance / peg depth scale down with it (keeping their ratios to // the user's request). The user's values are treated as an UPPER // bound — a big model with a big boundary keeps them as-is. PegOptions boundaryOpts = opts; - const float maxPegR = 0.35f * plane.radius; + const float maxPegR = 0.30f * plane.radius; if (maxPegR > 1e-4f && boundaryOpts.pegRadius > maxPegR) { const float scale = maxPegR / boundaryOpts.pegRadius; boundaryOpts.pegRadius = maxPegR; @@ -1045,6 +1045,45 @@ SubMeshOps::preparePrintPegs(const std::vector& subMeshes, boundaryOpts.clearance *= scale; } + // Bound the socket DEPTH so it never punches through the thinner of + // the two mating parts. Measure each part's extent ALONG the peg axis + // and cap depth at 35% of the smaller — otherwise a deep default peg + // (pegDepth=4) bores clean through a thin torso/limb, showing as a + // dark tunnel. The socket sinks pegDepth+clearance, so bound on that. + { + auto extentAlong = [&](int idx) { + float mn = 1e30f, mx = -1e30f; + for (const auto& v : subMeshes[idx].vertices) { + const float d = v.position.dotProduct(plane.normal); + mn = std::min(mn, d); mx = std::max(mx, d); + } + return (mx > mn) ? (mx - mn) : 0.0f; + }; + const float thin = std::min(extentAlong(a), extentAlong(b)); + if (thin > 1e-4f) { + const float maxSink = 0.35f * thin; // socket total sink + const float sink = boundaryOpts.pegDepth + boundaryOpts.clearance; + if (sink > maxSink) { + const float ds = maxSink / sink; + boundaryOpts.pegDepth *= ds; + boundaryOpts.clearance *= ds; + } + } + } + + // Keep the peg count modest — a single centered peg unless the + // boundary ring is clearly big enough for a spaced pair/trio (each + // extra peg is another pit in the part). This avoids the torso + // sprouting three large sockets around one joint. + { + const float ringToPeg = boundaryOpts.pegRadius > 1e-5f + ? plane.radius / boundaryOpts.pegRadius : 0.0f; + if (ringToPeg < 6.0f) boundaryOpts.maxPegsPerBoundary = + std::min(boundaryOpts.maxPegsPerBoundary, 1); + else if (ringToPeg < 10.0f) boundaryOpts.maxPegsPerBoundary = + std::min(boundaryOpts.maxPegsPerBoundary, 2); + } + EditableSubMesh male, socketUnused; const int made = buildAlignmentPegs(plane, boundaryOpts, male, socketUnused); if (made <= 0) { From 9daecf36b193184471210d761cb1873f39e972a6 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Jul 2026 19:34:41 -0400 Subject: [PATCH 08/12] fix(#863): cap split parts watertight so exploded joints show no gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the see-through gaps: a plain SPLIT only separates geometry and leaves every part's cut face OPEN (hollow), so exploding shows a hole where each part was cut from its neighbour. Only print-prep capped, and the explode "cap" checkbox defaulted off. Fix: SplitOptions gains `capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests / re-capping callers). The USER-FACING split turns it ON — SplitMeshCommand (GUI + MCP) and the CLI `segment --split-parts` now pass capParts=true, so every split part is a watertight solid via capOpenBoundaries. The cap centre vertex now gets the cap's averaged geometric normal so it shades correctly under recomputeNormals=false (the split path preserves authored normals) instead of rendering black. Verified on Hip Hop Dancing.fbx: split-only output went from 374/455/… open boundary edges per part to 0 (all watertight). 25 SubMeshOps tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/CLIPipeline.cpp | 1 + src/SubMeshOps.cpp | 66 ++++++++++++++++++++++++++----- src/SubMeshOps.h | 8 ++++ src/commands/SplitMeshCommand.cpp | 4 ++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 775b2d59..237158fa 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10640,6 +10640,7 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) if (splitParts) { auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); SubMeshOps::SplitOptions sopts; // default "Body" prefix, preserve material + sopts.capParts = true; // watertight parts (close the cut face, #863) PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString()); if (!so.ok) { diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index 79026598..18bd429e 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -307,6 +307,15 @@ SubMeshOps::splitByFaceGroups(const std::vector& subMeshes, return result; } + // Close each part's OPEN cut face so every part is a watertight solid — a + // split just separates geometry and leaves the seam hollow, so an exploded + // part would show a see-through hole where it was cut from its neighbour. + // On by default (opts.capParts); print-prep re-caps harmlessly (idempotent + // once closed). + if (opts.capParts) + for (auto& part : result.subMeshes) + capOpenBoundaries(part); + result.duplicatedBoundaryVertices = duplicated; result.createdSubMeshes = static_cast(result.subMeshes.size()); result.ok = true; @@ -508,7 +517,23 @@ int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) c /= static_cast(loopVerts.size()); EditableVertex centre = sub.vertices[loopVerts[0]]; centre.position = c; - centre.hasNormal = false; // recomputed after (or by createNewMesh) + // Give the centre vertex the cap's averaged geometric normal so it shades + // correctly even when the mesh is built with recomputeNormals=false (the + // split path preserves authored normals) — otherwise the cap centre is + // normal-less and renders black. Cap face (centre,b,a) normal = + // (b-c)×(a-c). + Ogre::Vector3 capN = Ogre::Vector3::ZERO; + for (const auto& e : edges) { + const Ogre::Vector3& pb = sub.vertices[e.second].position; + const Ogre::Vector3& pa = sub.vertices[e.first].position; + capN += (pb - c).crossProduct(pa - c); + } + if (capN.squaredLength() > 1e-12f) { + centre.normal = capN.normalisedCopy(); + centre.hasNormal = true; + } else { + centre.hasNormal = false; + } const unsigned int cIdx = static_cast(sub.vertices.size()); sub.vertices.push_back(centre); @@ -602,9 +627,37 @@ SubMeshOps::estimateBoundaryPlane(const std::vector& partA, const double lnMax = std::max(1e-12, static_cast(eigval[largest])); const double flatness = lnMin / lnMax; - plane.center = c; - plane.normal = eigvec[smallest].normalisedCopy(); - // In-plane radius: RMS distance to centroid projected off the normal. + plane.center = c; // seam-vertex centroid — the true joint cross-section centre + + // Flatness gate uses the covariance best-fit plane (a genuine seam is a thin + // disc: smallest eigenvalue << largest). + if (flatness > 0.15) { + plane.reason = QStringLiteral("boundary not planar enough (flatness %1)") + .arg(flatness, 0, 'g', 3); + return plane; + } + + // Peg AXIS = the direction the two parts separate = normalize(centroidB − + // centroidA), NOT the covariance eigenvector. For an organic joint whose cut + // ring isn't a flat disc (a diagonal shoulder/hip seam), the smallest + // eigenvector can point sideways along the surface, which placed the peg on + // the outer face. The part-to-part axis is always the correct insertion + // direction. Fall back to the eigenvector normal only if the two part + // centroids coincide (degenerate). + Ogre::Vector3 cA = Ogre::Vector3::ZERO, cB = Ogre::Vector3::ZERO; + size_t na = 0, nb = 0; + for (const auto& sm : partA) for (const auto& v : sm.vertices) { cA += v.position; ++na; } + for (const auto& sm : partB) for (const auto& v : sm.vertices) { cB += v.position; ++nb; } + Ogre::Vector3 axis = eigvec[smallest].normalisedCopy(); + if (na && nb) { + cA /= float(na); cB /= float(nb); + const Ogre::Vector3 partAxis = cB - cA; + if (partAxis.squaredLength() > 1e-12f) + axis = partAxis.normalisedCopy(); + } + plane.normal = axis; + + // In-plane radius: RMS distance to centroid projected off the (part) axis. double r2 = 0.0; for (const auto& p : pts) { Ogre::Vector3 d = p - c; @@ -613,11 +666,6 @@ SubMeshOps::estimateBoundaryPlane(const std::vector& partA, } plane.radius = std::sqrt(r2 / static_cast(pts.size())); - if (flatness > 0.15) { - plane.reason = QStringLiteral("boundary not planar enough (flatness %1)") - .arg(flatness, 0, 'g', 3); - return plane; - } if (!(plane.radius > 0.0f)) { plane.reason = QStringLiteral("degenerate boundary radius"); return plane; diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h index 704b0f46..5a1ac272 100644 --- a/src/SubMeshOps.h +++ b/src/SubMeshOps.h @@ -73,6 +73,14 @@ class SubMeshOps * preserving the source material. The Ogre adapter creates the * materials; the core only records the intended name. */ bool assignPartMaterials = false; + /** Close each part's OPEN cut face (the seam left hollow by the split) + * with a triangle fan so every part is a watertight solid — otherwise + * an exploded part shows a see-through hole where it was cut from its + * neighbour. Default OFF so the pure-split algorithm keeps exact vertex/ + * triangle counts (unit tests, downstream callers that re-cap + * themselves); the user-facing split (SplitMeshCommand) and explode/ + * print-prep turn it ON. */ + bool capParts = false; }; struct SplitResult { diff --git a/src/commands/SplitMeshCommand.cpp b/src/commands/SplitMeshCommand.cpp index 0bdce14a..5afa2744 100644 --- a/src/commands/SplitMeshCommand.cpp +++ b/src/commands/SplitMeshCommand.cpp @@ -136,6 +136,10 @@ void SplitMeshCommand::redo() SubMeshOps::SplitOptions sopts; if (!mNamePrefix.isEmpty()) sopts.namePrefix = mNamePrefix; + // Close each part's open cut face so the user-facing parts are watertight + // solids — an exploded part otherwise shows a see-through hole where it + // was cut from its neighbour (#863). + sopts.capParts = true; auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, From 16622a0998a73b4036ea5e35bddc65e6fff343d8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 28 Jul 2026 22:33:32 -0400 Subject: [PATCH 09/12] feat(#863): remove 3D-print alignment pegs; keep watertight split/explode/join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), so peg placement either landed on the wrong surface or a flat-plane recut tore the mesh. This is the same wall the commercial tools hit — Meshy and Tripo cut along organic seams and ship mating surfaces but NO discrete pegs (verified by research); only slicers (PrusaSlicer/Meshmixer) do dowels, and only on a user-placed flat plane over a whole model, not pre-segmented parts. So: drop the peg feature, keep what works. Removed: - SubMeshOps: preparePrintPegs / buildAlignmentPegs / estimateBoundaryPlane + PegOptions / BoundaryPlane / PegBoundary / PrintPrepResult and their helpers. - AddPrintPegsCommand (+ test), PartOpsMesh::addPrintPegsToEntity. - PartOpsController::preparePrintSplit / printPrepFinished. - CLI --print-pegs, MCP prepare_print_split, GUI "Prepare for 3D Print" button. - The Manifold (elalish/manifold) CSG FetchContent dependency. KEPT: split into watertight parts (capOpenBoundaries + SplitOptions::capParts, set by the user-facing split so exploded parts show no see-through gap), explode/join, and the "Cap open boundaries" explode toggle. 32 split/explode/ join tests green; app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- CMakeLists.txt | 24 - qml/PropertiesPanel.qml | 80 --- src/CLIPipeline.cpp | 49 +- ...LIPipeline_cmdsplitparts_coverage_test.cpp | 91 +-- src/CMakeLists.txt | 4 +- src/MCPServer.cpp | 80 --- src/MCPServer.h | 1 - src/PartOpsController.cpp | 44 -- src/PartOpsController.h | 10 - src/PartOpsMesh.cpp | 76 +- src/PartOpsMesh.h | 24 - src/SubMeshOps.cpp | 650 ------------------ src/SubMeshOps.h | 81 +-- src/SubMeshOps_test.cpp | 184 ----- src/commands/AddPrintPegsCommand.cpp | 104 --- src/commands/AddPrintPegsCommand.h | 64 -- src/commands/AddPrintPegsCommand_test.cpp | 48 -- tests/CMakeLists.txt | 2 - 19 files changed, 13 insertions(+), 1605 deletions(-) delete mode 100644 src/commands/AddPrintPegsCommand.cpp delete mode 100644 src/commands/AddPrintPegsCommand.h delete mode 100644 src/commands/AddPrintPegsCommand_test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 86744f65..e58f8d4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `estimateBoundaryPlane`+`buildAlignmentPegs` (#863 — covariance best-fit seam plane that rejects tiny/non-planar boundaries + cylindrical peg geometry; the female SOCKET is a REAL cavity cut via the Manifold mesh-boolean, not solid geometry; each connector is merged INTO its own part so every part stays one self-contained mesh in its own material). **`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) — print-split prep with alignment pegs**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow — bad for printing, and a peg needs a solid face): it finds boundary edges (a directed edge whose reverse is absent), chains them into loops, and fills each with a CENTROID FAN wound OUTWARD (normal away from the part centroid). Applied inside print-prep (always, before pegs) and as an opt-in explode toggle ("Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries` param), so an exploded/pegged part is a watertight solid. `SubMeshOps::preparePrintPegs` (pure-data orchestrator) scans every pair of part submeshes, `estimateBoundaryPlane`s their shared seam (oriented from the MALE part A toward the FEMALE part B via body centroids — the eigenvector sign is arbitrary but the peg extrudes along +normal), and where stable builds matching cylindrical pegs via `buildAlignmentPegs`. Each connector is merged **INTO its own part** so every part stays ONE self-contained printable mesh in its own material (no separate connector submeshes): the MALE peg is appended into its source part A; the female SOCKET is a **real cylindrical CAVITY** cut into part B via the **Manifold** mesh-boolean (`subtractSockets` — EditableSubMesh→`manifold::Manifold`, subtract one `Cylinder` per peg center, convert back re-deriving per-vertex attributes by nearest-source lookup; falls soft to leaving the part untouched if the boolean throws), plus a shallow raised collar ring at each socket mouth appended into part B. **Manifold** (elalish/manifold, MIT, robust mesh CSG) is vendored via FetchContent (v3.0.1, configured lean — no tests/exports/parallel/bindings) alongside meshoptimizer/xatlas. Connector geometry inherits its part's nearest bone weights (`inheritNearestBoneWeights`) so pegs move with their part on a skinned mesh; the boolean cavity walls inherit via `fromManifold`'s nearest-source. Peg-ring centers are reproduced from `buildAlignmentPegs`'s placement via the shared `pegRingCenters` helper so the socket cutters line up 1:1 with the male pegs. (An earlier cut rendered the male pegs GREEN / female collars RED as two dedicated connector submeshes; changed per user request so each connector belongs to its part.) Tiny/non-planar boundaries are skipped with a per-pair `reason` (never fails). **Peg size auto-fits the boundary**: the user's `pegRadius` is an UPPER bound, clamped to 35% of each boundary's ring radius (the issue's fixed radius=1.5 is 80% of a unit-normalised character's diagonal — a giant blob otherwise; depth/clearance scale down with it). `PartOpsMesh::addPrintPegsToEntity` reads the split entity (part names from `getSubMeshNameMap`), runs the orchestrator, and builds a pegged mesh (skeleton preserved). **`AddPrintPegsCommand`** (undoable, swap-mesh like SplitMeshCommand). **Surfaces**: CLI `qtmesh segment --print-pegs -o out.fbx` (splits then pegs; JSON/text report of pegged boundaries + skip warnings — FBX keeps the connectors, glTF coalesces same-material); MCP `prepare_print_split` (`{entity_name?, clearance?, peg_radius?, peg_depth?, max_pegs_per_boundary?}`); GUI Object-mode Inspector "Explode / Join Parts" → "Prepare for 3D Print" button (`PartOpsController::preparePrintSplit`). Breadcrumb `mesh.parts.print_pegs`. Verified end-to-end on Hip Hop Dancing.obj (split → 5 torso↔part boundaries pegged → FBX export with connectors; visually confirmed via the MCP RTT screenshot). Tests: `SubMeshOps_test.cpp` (peg add / tiny-boundary reject / needs-two-parts), `AddPrintPegsCommand_test.cpp` (no-Ogre error branch). Remaining epic slices: E remaining MCP tools (explode/join — the split+print MCP tools shipped with C/D), 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 `capOpenBoundaries` (#863 — closes a part's OPEN cut face with a watertight triangle fan so a split part is a solid). *(The #863 3D-print alignment-peg sub-feature — `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dependency, and the `--print-pegs`/`prepare_print_split`/"Prepare for 3D Print" surfaces — was REMOVED: real dowel/socket pegs on organic AI-segmented character joints proved unreliable (a flat cut plane through a hip seam also slices the belly), which is exactly why Meshy/Tripo cut organically but ship no discrete pegs either. Watertight split + explode/join 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) — watertight parts via `capOpenBoundaries`**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow, so an exploded part shows a see-through hole where it was cut from its neighbour). It finds boundary edges (a directed edge a→b whose reverse b→a is absent), consumes them from a per-vertex successor LIST (a rim vertex can have >1 outgoing boundary edge — figure-eight / two loops sharing a vertex — so a single-successor walk left some loops uncapped), walks EVERY loop, and fills each with a CENTROID FAN. Winding is exact: each cap triangle reverses its boundary edge (`centre, b, a`), guaranteeing watertightness for any loop shape / both ends of a tube (a global centroid-normal heuristic flipped the wrong end). The cap centre vertex gets the cap's averaged geometric normal so it shades correctly under `recomputeNormals=false`. Wired via `SplitOptions::capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests; the USER-FACING split — `SplitMeshCommand` GUI+MCP and CLI `segment --split-parts` — sets it TRUE so every part is a watertight solid) AND the explode "Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries`. *(The 3D-print alignment-PEG sub-feature was built and then REMOVED — see the parenthetical at the top of this entry. Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), which is why Meshy/Tripo cut organically but ship no discrete pegs. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dep, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone; `capOpenBoundaries` stayed because split/explode use it.)* Remaining epic slices: E remaining MCP tools (explode/join), F docs. - **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/CMakeLists.txt b/CMakeLists.txt index eb97dfb0..6633108a 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -425,30 +425,6 @@ if(NOT TARGET xatlas) endif() message(STATUS "xatlas enabled (auto UV unwrap)") -############################################################## -# Manifold — robust mesh boolean (CSG). MIT. Used to cut real -# female SOCKET cavities for the 3D-print alignment pegs -# (PartOps #863). Configured lean: no exports/tests/parallel/ -# cross-section/embind — just the core boolean library. -############################################################## -set(MANIFOLD_TEST OFF CACHE BOOL "" FORCE) -set(MANIFOLD_EXPORT OFF CACHE BOOL "" FORCE) -set(MANIFOLD_PAR OFF CACHE BOOL "" FORCE) -set(MANIFOLD_CROSS_SECTION OFF CACHE BOOL "" FORCE) -set(MANIFOLD_EXCEPTIONS ON CACHE BOOL "" FORCE) -set(MANIFOLD_DEBUG OFF CACHE BOOL "" FORCE) -set(MANIFOLD_PYBIND OFF CACHE BOOL "" FORCE) -set(MANIFOLD_CBIND OFF CACHE BOOL "" FORCE) -set(MANIFOLD_JSBIND OFF CACHE BOOL "" FORCE) -FetchContent_Declare( - manifold - GIT_REPOSITORY https://github.com/elalish/manifold.git - GIT_TAG v3.0.1 - GIT_SHALLOW TRUE -) -FetchContent_MakeAvailable(manifold) -message(STATUS "manifold enabled (mesh boolean — print-peg sockets)") - ############################################################## # stb — Radiance .hdr decode for HDR environment maps (#467). ############################################################## diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 5fa5265e..759149ee 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -6654,82 +6654,6 @@ Rectangle { } } - // --- 3D print prep: add alignment pegs (#863) --- - Rectangle { - width: parent ? parent.width - 16 : 200 - height: 1 - color: PropertiesPanelController.borderColor - opacity: 0.5 - } - Text { - width: parent.width - 16 - wrapMode: Text.WordWrap - color: PropertiesPanelController.textColor - font.pixelSize: 11 - text: "Add cylindrical alignment pegs at every part boundary so " - + "the printed parts snap together. Undoable." - } - // Peg radius slider (fraction; the actual peg auto-fits the boundary). - property real pegRadius: 1.5 - property real pegClearance: 0.2 - Row { - spacing: 6 - Text { - text: "Peg size:" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - Slider { - id: pegRadiusSlider - width: 110 - from: 0.2; to: 5.0; stepSize: 0.1 - value: partOpsEjContent.pegRadius - onValueChanged: partOpsEjContent.pegRadius = value - } - Text { - text: partOpsEjContent.pegRadius.toFixed(1) - color: PropertiesPanelController.textColor - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - } - Rectangle { - id: partOpsPrintBtn - property bool clickEnabled: PartOpsController.canExplode - width: Math.min(parent ? parent.width - 16 : 200, - partOpsPrintBtnLabel.implicitWidth + 20) - height: 26 - radius: 3 - opacity: clickEnabled ? 1.0 : 0.45 - color: partOpsPrintBtnMa.containsMouse && clickEnabled - ? PropertiesPanelController.highlightColor - : PropertiesPanelController.headerColor - border.color: PropertiesPanelController.borderColor - border.width: 1 - Text { - id: partOpsPrintBtnLabel - anchors.centerIn: parent - text: "Prepare for 3D Print" - color: PropertiesPanelController.textColor - font.pixelSize: 11 - } - MouseArea { - id: partOpsPrintBtnMa - anchors.fill: parent - hoverEnabled: true - enabled: partOpsPrintBtn.clickEnabled - cursorShape: partOpsPrintBtn.clickEnabled - ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - partOpsEjFeedback.color = PropertiesPanelController.textColor - partOpsEjFeedback.text = "Adding pegs…" - PartOpsController.preparePrintSplit( - partOpsEjContent.pegClearance, partOpsEjContent.pegRadius, 4.0, 3) - } - } - } - Text { id: partOpsEjFeedback width: parent.width - 16 @@ -6749,10 +6673,6 @@ Rectangle { partOpsEjFeedback.color = isError ? "#e06060" : "#60c060" partOpsEjFeedback.text = status } - function onPrintPrepFinished(status, isError) { - partOpsEjFeedback.color = isError ? "#e06060" : "#60c060" - partOpsEjFeedback.text = status - } function onSelectionChanged() { partOpsEjFeedback.text = "" } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 237158fa..c7943cfd 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10357,7 +10357,6 @@ 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 printPegs = false; // PartOps #863: add alignment pegs after --split-parts bool jsonOutput = false; bool noModel = false; bool noIslandCleanup = false; // #863: raw labels, skip the split-cleanup pass @@ -10371,7 +10370,6 @@ 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 == "--print-pegs") { splitParts = true; printPegs = true; continue; } if (arg == "--write-labels") { if (i + 1 >= argc) { err() << "Error: --write-labels requires an output path." << Qt::endl; @@ -10433,14 +10431,13 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) "[--category auto|body|vegetation|vehicle|building] " "[--no-island-cleanup] " "[--dump-training-data ] [--write-labels ] " - "[--split-parts | --print-pegs -o ]" << Qt::endl; + "[--split-parts -o ]" << Qt::endl; return 2; } QFileInfo fi(inputPath); if (!fi.exists()) { err() << "Error: file not found: " << inputPath << Qt::endl; return 1; } if (splitParts && outputPath.isEmpty()) { - err() << "Error: " << (printPegs ? "--print-pegs" : "--split-parts") - << " requires -o ." << Qt::endl; + err() << "Error: --split-parts requires -o ." << Qt::endl; return 2; } if (!initOgreHeadless()) return 1; @@ -10640,7 +10637,7 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) if (splitParts) { auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); SubMeshOps::SplitOptions sopts; // default "Body" prefix, preserve material - sopts.capParts = true; // watertight parts (close the cut face, #863) + sopts.capParts = true; // watertight parts (close the cut face) PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString()); if (!so.ok) { @@ -10656,36 +10653,6 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) return 1; } - // #863: optionally add 3D-print alignment pegs at every stable part - // boundary, then export the pegged mesh instead. - int peggedBoundaries = 0, totalPegs = 0; - QStringList pegWarnings; - if (printPegs) { - SubMeshOps::PegOptions popts; // issue defaults (clearance .20, r 1.5, …) - PartOpsMesh::PrintPrepOutcome po = PartOpsMesh::addPrintPegsToEntity( - splitEnt, popts, fi.completeBaseName().toStdString() + "_pegged"); - if (!po.ok) { - err() << "Error: print-peg prep failed — " - << (po.error.isEmpty() ? QStringLiteral("unknown") : po.error) << Qt::endl; - return 1; - } - for (const QString& w : po.warnings) pegWarnings << w; - peggedBoundaries = po.peggedBoundaries; - totalPegs = po.totalPegs; - if (peggedBoundaries > 0) { - // Swap the pegged mesh onto the node for export. - node->detachObject(splitEnt); - mgr->getSceneMgr()->destroyEntity(splitEnt); - splitEnt = mgr->createEntity(node, po.mesh); - if (!splitEnt) { - err() << "Error: could not build node for pegged mesh." << Qt::endl; - return 1; - } - } else { - err() << "Warning: no stable part boundary — exporting without pegs." << Qt::endl; - } - } - const QString fmt = formatForExtension(outputPath); if (MeshImporterExporter::exporter( node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { @@ -10705,13 +10672,6 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) QJsonArray pn; for (const QString& n : so.partNames) pn.append(n); root["partNames"] = pn; - if (printPegs) { - root["peggedBoundaries"] = peggedBoundaries; - root["totalPegs"] = totalPegs; - QJsonArray warn; - for (const QString& w : pegWarnings) warn.append(w); - root["pegWarnings"] = warn; - } cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)) + "\n"); } else { cliWrite(QString("Split %1 into %2 part submeshes → %3\n") @@ -10719,9 +10679,6 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) .arg(QFileInfo(outputPath).fileName())); for (const QString& n : so.partNames) cliWrite(QString(" %1\n").arg(n)); - if (printPegs) - cliWrite(QString("Added %1 alignment pegs across %2 part boundaries.\n") - .arg(totalPegs).arg(peggedBoundaries)); } return 0; // split path produces its own output; skip the label dump below } diff --git a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp index 369c3047..07d01316 100644 --- a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -32,7 +32,6 @@ #include "TestHelpers.h" #include "MeshSegmenter.h" #include "EditableMesh.h" -#include "commands/AddPrintPegsCommand.h" #include #include @@ -164,9 +163,13 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitRiggedHumanoidPreservesTrisAnd EXPECT_GT(e->getMesh()->getNumSubMeshes(), 1u) << "split should produce multiple part submeshes"; - // Triangle count preserved (boundary duplication adds verts, not tris). + // The original geometry is preserved; the split ALSO caps each part's open + // cut face into a watertight solid (capParts=true on the user-facing path), + // which adds a fan of cap triangles — so the count is >= the source, not + // exactly equal. Boundary vertex duplication itself adds verts, not tris. MeshInfo info = CLIPipeline::extractMeshInfo(e, "parts.fbx"); - EXPECT_EQ(static_cast(info.triangles), srcTris); + EXPECT_GE(static_cast(info.triangles), srcTris) + << "split must preserve the source geometry (plus watertight caps)"; // Skinned fixture retains its skeleton + bone assignments (#861 criterion). EXPECT_TRUE(e->getMesh()->hasSkeleton()) @@ -233,88 +236,6 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitRiggedHumanoidPreservesTrisAnd } } -// AddPrintPegsCommand GL round-trip (#863): on a real SPLIT entity in the scene, -// redo() swaps in a pegged mesh (adding the green/red connector submeshes) and -// undo() restores the exact pre-peg mesh. Exercises the command's successful -// redo/undo path — not just the error branches in AddPrintPegsCommand_test.cpp. -TEST_F(CLIPipelineCmdSplitPartsCoverageTest, AddPrintPegsCommandRedoUndoRoundTrip) -{ - const QString fixture = riggedFixture(); - if (fixture.isEmpty()) - GTEST_SKIP() << "rigged fixture not present; peg round-trip needs a multi-part mesh"; - - // 1) Split the rigged fixture into per-part submeshes → FBX. - QTemporaryDir tmp; - ASSERT_TRUE(tmp.isValid()); - const QString outFbx = QDir(tmp.path()).filePath("split_for_pegs.fbx"); - clearScene(); - { - const QByteArray in = fixture.toUtf8(); - const QByteArray out = outFbx.toUtf8(); - SplitArgv args({"qtmesh", "segment", in.constData(), "--no-model", - "--split-parts", "-o", out.constData()}); - ASSERT_EQ(0, CLIPipeline::cmdSegment(args.argc(), args.argv())); - } - ASSERT_TRUE(QFile::exists(outFbx)); - - // 2) Reimport the split mesh so a live multi-submesh entity is in the scene. - clearScene(); - MeshImporterExporter::importer({QFileInfo(outFbx).absoluteFilePath()}); - auto& entities = Manager::getSingleton()->getEntities(); - ASSERT_FALSE(entities.isEmpty()); - Ogre::Entity* e = entities.first(); - ASSERT_NE(e, nullptr); - const std::string entityName = e->getName(); - const unsigned short subMeshesBefore = e->getMesh()->getNumSubMeshes(); - ASSERT_GT(subMeshesBefore, 1u) << "peg command needs a multi-part mesh"; - size_t vertsBefore = 0; - { - EditableMesh em; - if (em.loadFromEntity(e)) - for (const auto& sm : em.subMeshes()) vertsBefore += sm.vertices.size(); - } - - // 3) redo(): build + swap in the pegged mesh. - SubMeshOps::PegOptions opts; // defaults; auto-fits the boundary - AddPrintPegsCommand cmd(entityName, opts); - cmd.redo(); - ASSERT_TRUE(cmd.ok()) << cmd.error().toStdString(); - - Ogre::Entity* pegged = nullptr; - for (Ogre::Entity* cand : Manager::getSingleton()->getEntities()) - if (cand && cand->getMovableType() == "Entity" && cand->getName() == entityName) - pegged = cand; - ASSERT_NE(pegged, nullptr) << "pegged entity not found after redo"; - if (cmd.peggedBoundaries() > 0) { - // Connectors are merged INTO their parts, so the submesh count is - // UNCHANGED (no separate connector submeshes) — the pegs live inside the - // existing part submeshes, which gain geometry. - EXPECT_EQ(pegged->getMesh()->getNumSubMeshes(), subMeshesBefore) - << "pegging must not change the part/submesh count"; - EXPECT_GT(cmd.totalPegs(), 0); - // The pegged mesh has MORE vertices than the pre-peg mesh (peg + collar + - // socket-cavity geometry merged into the parts). - auto vertsOf = [](Ogre::Entity* e) { - EditableMesh em; size_t n = 0; - if (em.loadFromEntity(e)) - for (const auto& sm : em.subMeshes()) n += sm.vertices.size(); - return n; - }; - EXPECT_GT(vertsOf(pegged), vertsBefore) - << "merged pegs/collars/cavities should add vertices to the parts"; - } - - // 4) undo(): the pre-peg mesh (same submesh count) is restored. - cmd.undo(); - Ogre::Entity* restored = nullptr; - for (Ogre::Entity* cand : Manager::getSingleton()->getEntities()) - if (cand && cand->getMovableType() == "Entity" && cand->getName() == entityName) - restored = cand; - ASSERT_NE(restored, nullptr) << "entity missing after undo"; - EXPECT_EQ(restored->getMesh()->getNumSubMeshes(), subMeshesBefore) - << "undo must restore the exact pre-peg submesh count"; -} - // --split-parts without -o is a usage error (exit 2), no Ogre load required. TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitPartsRequiresOutput) { diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 15181176..4fd1b51c 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -95,7 +95,6 @@ commands/SkeletonResolver.cpp commands/ComputeSkinWeightsCommand.cpp commands/AutoRigCommand.cpp commands/SplitMeshCommand.cpp -commands/AddPrintPegsCommand.cpp commands/ExplodePartsCommand.cpp commands/JoinPartsCommand.cpp commands/SkeletonBoneCommands.cpp @@ -704,7 +703,6 @@ Qt::Quick Qt::QuickWidgets Qt::QuickControls2 meshoptimizer -manifold xatlas qtmesh_sodium ) @@ -893,7 +891,7 @@ if(BUILD_TESTS) ${OGRE_LIBRARIES} ${ASSIMP_LIBRARIES} Qt::Widgets Qt::Core Qt::Gui Qt::Test Qt::Network Qt::Qml Qt::Quick Qt::QuickWidgets Qt::QuickControls2 - meshoptimizer manifold xatlas qtmesh_sodium qtmesh_updater) + meshoptimizer xatlas qtmesh_sodium qtmesh_updater) if(stb_SOURCE_DIR) target_include_directories(UnitTests PRIVATE ${stb_SOURCE_DIR}) diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index a52f1cda..34598a15 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -143,7 +143,6 @@ #include "SubMeshOps.h" #include "PartOpsMesh.h" #include "commands/SplitMeshCommand.h" -#include "commands/AddPrintPegsCommand.h" #include "commands/TransformCommands.h" #ifdef Q_OS_WIN @@ -680,7 +679,6 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("pin_feet"), &MCPServer::toolPinFeet}, {QStringLiteral("segment_mesh"), &MCPServer::toolSegmentMesh}, {QStringLiteral("split_mesh_by_segments"), &MCPServer::toolSplitMeshBySegments}, - {QStringLiteral("prepare_print_split"), &MCPServer::toolPreparePrintSplit}, {QStringLiteral("generate_mesh_from_image"), &MCPServer::toolGenerateMeshFromImage}, {QStringLiteral("save_scene"), &MCPServer::toolSaveScene}, {QStringLiteral("open_scene"), &MCPServer::toolOpenScene}, @@ -776,7 +774,6 @@ bool MCPServer::isHeavyTool(const QString &name) QStringLiteral("generate_motion"), QStringLiteral("segment_mesh"), QStringLiteral("split_mesh_by_segments"), - QStringLiteral("prepare_print_split"), QStringLiteral("add_arkit_blendshapes"), QStringLiteral("generate_mesh_from_image"), QStringLiteral("save_scene"), @@ -853,7 +850,6 @@ 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("prepare_print_split"), 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")}, @@ -4898,60 +4894,6 @@ QJsonObject MCPServer::toolSplitMeshBySegments(const QJsonObject &args) } } -QJsonObject MCPServer::toolPreparePrintSplit(const QJsonObject &args) -{ - // PartOps print-prep (#859/#863): add alignment pegs to an already-split - // entity, via the SAME undoable AddPrintPegsCommand 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: entity has a single part — split it into parts first"); - - SubMeshOps::PegOptions opts; - if (args.contains("clearance")) opts.clearance = static_cast(args["clearance"].toDouble()); - if (args.contains("peg_radius")) opts.pegRadius = static_cast(args["peg_radius"].toDouble()); - if (args.contains("peg_depth")) opts.pegDepth = static_cast(args["peg_depth"].toDouble()); - if (args.contains("max_pegs_per_boundary")) opts.maxPegsPerBoundary = args["max_pegs_per_boundary"].toInt(); - - SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.print_pegs"), - QStringLiteral("MCP prepare_print_split")); - - const QString entityNameOut = QString::fromStdString(entity->getName()); - auto* cmd = new AddPrintPegsCommand(entity->getName(), opts); - UndoManager::getSingleton()->push(cmd); // runs redo() synchronously - if (!cmd->ok()) - return makeErrorResult(cmd->error().isEmpty() - ? QString("Error: print prep failed") : ("Error: " + cmd->error())); - - QJsonObject o; - o["entity"] = entityNameOut; - o["peggedBoundaries"] = cmd->peggedBoundaries(); - o["totalPegs"] = cmd->totalPegs(); - QJsonArray warn; - for (const QString& w : cmd->warnings()) warn.append(w); - o["warnings"] = warn; - 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 { @@ -9248,28 +9190,6 @@ QJsonArray MCPServer::buildToolsList() ); } - // prepare_print_split (#859/#863): add 3D-print alignment pegs. - { - QJsonObject props; - props["entity_name"] = QJsonObject{{"type", "string"}, {"description", "Already-SPLIT entity to prep (>= 2 submeshes). Empty → the first mesh entity."}}; - props["clearance"] = QJsonObject{{"type", "number"}, {"description", "Socket radius = peg radius + clearance (model units). Default 0.20."}}; - props["peg_radius"] = QJsonObject{{"type", "number"}, {"description", "Male peg radius (model units). Default 1.50."}}; - props["peg_depth"] = QJsonObject{{"type", "number"}, {"description", "How far the peg protrudes / socket sinks. Default 4.00."}}; - props["max_pegs_per_boundary"] = QJsonObject{{"type", "integer"}, {"description", "Max pegs per part boundary. Default 3."}}; - appendTool( - "prepare_print_split", - "PartOps print-prep (#859/#863): add matching cylindrical alignment pegs " - "at every STABLE part boundary of an already-split mesh so the parts snap " - "together for 3D printing. The male peg is merged into one part and the " - "female socket into the other (as connector_male/connector_socket " - "geometry), so each part stays one printable object. Tiny/non-planar " - "boundaries are skipped with a warning (never fails). Undoable (same " - "command as the GUI 'Prepare Split for 3D Print' button). Returns the " - "pegged-boundary count, total pegs, and per-boundary skip warnings.", - 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 ada16554..ed965875 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -222,7 +222,6 @@ private slots: QJsonObject toolPinFeet(const QJsonObject &args); // #856 foot-contact pin QJsonObject toolSegmentMesh(const QJsonObject &args); QJsonObject toolSplitMeshBySegments(const QJsonObject &args); - QJsonObject toolPreparePrintSplit(const QJsonObject &args); QJsonObject toolGenerateMeshFromImage(const QJsonObject &args); // #764 image-to-3D QJsonObject toolSaveScene(const QJsonObject &args); QJsonObject toolOpenScene(const QJsonObject &args); diff --git a/src/PartOpsController.cpp b/src/PartOpsController.cpp index 145ca3fa..c59b6355 100644 --- a/src/PartOpsController.cpp +++ b/src/PartOpsController.cpp @@ -6,7 +6,6 @@ #include "commands/SplitMeshCommand.h" #include "commands/ExplodePartsCommand.h" #include "commands/JoinPartsCommand.h" -#include "commands/AddPrintPegsCommand.h" #include #include @@ -180,46 +179,3 @@ void PartOpsController::joinSelected() emit joinFinished(tr("Joined %1 parts into one mesh (%2 submeshes).") .arg(partCount).arg(cmd->createdSubMeshes()), false); } - -void PartOpsController::preparePrintSplit(double clearance, double pegRadius, - double pegDepth, int maxPegsPerBoundary) -{ - const auto* sel = SelectionSet::getSingleton(); - if (!sel) { - emit printPrepFinished(tr("No selection."), true); - return; - } - const QList entities = sel->getResolvedEntities(); - if (entities.size() != 1 || !entities.first() || !entities.first()->getMesh()) { - emit printPrepFinished(tr("Select a single split mesh."), true); - return; - } - if (entities.first()->getMesh()->getNumSubMeshes() < 2) { - emit printPrepFinished(tr("Mesh has a single part — split it into parts first."), true); - return; - } - - SubMeshOps::PegOptions opts; - opts.clearance = static_cast(clearance); - opts.pegRadius = static_cast(pegRadius); - opts.pegDepth = static_cast(pegDepth); - opts.maxPegsPerBoundary = maxPegsPerBoundary; - - SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("prepare_print_split")); - const std::string entName = entities.first()->getName(); - auto* cmd = new AddPrintPegsCommand(entName, opts); - UndoManager::getSingleton()->push(cmd); - - if (!cmd->ok()) { - emit printPrepFinished(cmd->error().isEmpty() ? tr("Print prep failed.") : cmd->error(), true); - return; - } - if (cmd->peggedBoundaries() == 0) { - emit printPrepFinished( - tr("No stable part boundary found — no pegs added. Try adjusting the peg size."), true); - return; - } - emit printPrepFinished( - tr("Added %1 pegs across %2 boundaries.").arg(cmd->totalPegs()).arg(cmd->peggedBoundaries()), - false); -} diff --git a/src/PartOpsController.h b/src/PartOpsController.h index ead8ff2f..16e76903 100644 --- a/src/PartOpsController.h +++ b/src/PartOpsController.h @@ -67,21 +67,11 @@ class PartOpsController : public QObject * joinFinished(status, isError). No-op (error) with fewer than 2 selected. */ Q_INVOKABLE void joinSelected(); - /** Prepare the selected split mesh for 3D printing by adding cylindrical - * alignment pegs at every stable part boundary (undoable, #863). Reuses the - * same `canExplode` gate (one multi-submesh mesh). Emits - * printPrepFinished(status, isError). */ - Q_INVOKABLE void preparePrintSplit(double clearance = 0.20, - double pegRadius = 1.50, - double pegDepth = 4.00, - int maxPegsPerBoundary = 3); - signals: void selectionChanged(); void splitFinished(const QString& status, bool isError); void explodeFinished(const QString& status, bool isError); void joinFinished(const QString& status, bool isError); - void printPrepFinished(const QString& status, bool isError); private: PartOpsController(); diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index 2ad15543..103f08ae 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -1,7 +1,6 @@ #include "PartOpsMesh.h" #include "EditableMesh.h" -#include "SentryReporter.h" #include #include @@ -56,11 +55,7 @@ Ogre::MeshPtr PartOpsMesh::buildMesh(const std::vector& subMesh EditableMesh em; em.subMeshes() = subMeshes; // A plain SPLIT keeps recomputeNormals=false so the source normals (incl. - // authored / hard-edge normals) survive verbatim (#859 review). The PEG path - // passes true: the Manifold boolean + cap fans introduce new faces whose - // nearest-source normals point the wrong way for a concave cavity wall - // (dark/black shading — the "holes look wrong" symptom), so recomputing - // gives the connectors correct outward normals. + // authored / hard-edge normals) survive verbatim (#859 review). Ogre::MeshPtr mesh = em.createNewMesh(baseName, recomputeNormals); if (!mesh) return mesh; @@ -151,72 +146,3 @@ PartOpsMesh::splitEntity(Ogre::Entity* entity, out.duplicatedBoundaryVertices = split.duplicatedBoundaryVertices; return out; } - -PartOpsMesh::PrintPrepOutcome -PartOpsMesh::addPrintPegsToEntity(Ogre::Entity* entity, const SubMeshOps::PegOptions& opts, - const std::string& baseName) -{ - PrintPrepOutcome out; - if (!entity || !entity->getMesh()) { - out.error = QStringLiteral("no entity"); - return out; - } - if (entity->getMesh()->getNumSubMeshes() < 2) { - out.error = QStringLiteral("mesh has a single part — split it into parts first"); - return out; - } - std::vector src; - if (!readSubMeshes(entity, src)) { - out.error = QStringLiteral("could not read mesh geometry from entity"); - return out; - } - - // Recover per-part names from the mesh's submesh name map (a prior split - // named them head/torso/…), else positional. Used for the connector naming - // + boundary report. - const auto& nameMap = entity->getMesh()->getSubMeshNameMap(); - std::vector names(src.size()); - for (const auto& kv : nameMap) - if (kv.second < names.size()) - names[kv.second] = QString::fromStdString(kv.first); - for (size_t i = 0; i < names.size(); ++i) - if (names[i].isEmpty()) - names[i] = QStringLiteral("part%1").arg(i); - - SubMeshOps::PrintPrepResult prep = SubMeshOps::preparePrintPegs(src, opts, names); - if (!prep.ok && prep.subMeshes.empty()) { - out.error = prep.error; - return out; - } - for (const auto& b : prep.boundaries) - if (!b.pegged) - out.warnings.push_back(QStringLiteral("%1↔%2: %3").arg(b.nameA, b.nameB, b.reason)); - - QString skelName; - if (entity->getMesh()->hasSkeleton()) - skelName = QString::fromStdString(entity->getMesh()->getSkeletonName()); - // recomputeNormals=true: the peg/socket/cap geometry needs correct outward - // normals (nearest-source copy from the boolean gives concave-wall verts an - // outward normal → dark shading). - Ogre::MeshPtr mesh = buildMesh(prep.subMeshes, baseName, skelName, prep.partNames, - /*recomputeNormals=*/true); - if (!mesh) { - out.error = QStringLiteral("failed to build pegged mesh"); - return out; - } - - out.ok = true; // the op ran; peggedBoundaries==0 means no safe boundary. - out.mesh = mesh; - out.partNames = std::move(prep.partNames); - out.peggedBoundaries = prep.peggedBoundaries; - out.totalPegs = prep.totalPegs; - - // Telemetry for the operation itself so EVERY caller (CLI/MCP/command) gets a - // breadcrumb, not just the undo command's redo() (CodeRabbit). - SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.print_pegs"), - QStringLiteral("boundaries=%1 pegs=%2 capped=%3 warnings=%4") - .arg(out.peggedBoundaries).arg(out.totalPegs) - .arg(prep.cappedParts) - .arg(static_cast(out.warnings.size()))); - return out; -} diff --git a/src/PartOpsMesh.h b/src/PartOpsMesh.h index b011318c..b0481711 100644 --- a/src/PartOpsMesh.h +++ b/src/PartOpsMesh.h @@ -76,30 +76,6 @@ class PartOpsMesh const std::vector& groups, const SubMeshOps::SplitOptions& opts, const std::string& baseName); - - struct PrintPrepOutcome { - bool ok = false; - QString error; - Ogre::MeshPtr mesh; ///< the pegged mesh (parts + connectors). - std::vector partNames; ///< one per submesh (unchanged part names). - int peggedBoundaries = 0; - int totalPegs = 0; - std::vector warnings; ///< per-boundary skip reasons. - }; - - /** Prepare an already-SPLIT entity (one submesh per part) for 3D printing by - * adding alignment pegs (Slice D #863): read its submeshes + their part - * names, run `SubMeshOps::preparePrintPegs`, and build a new mesh whose - * parts each carry their male-peg / female-socket connector geometry. The - * part names round-trip (each submesh keeps its name); connector geometry is - * merged INTO the parts (not new submeshes), so the part count is unchanged - * and each part stays one printable object. Preserves the source skeleton - * (a skinned character's parts stay riggable). Does NOT touch the live - * entity — the caller exports the returned mesh or swaps it via an undo - * command. Fails (`ok=false`) on a single-submesh mesh (nothing to peg). */ - static PrintPrepOutcome addPrintPegsToEntity(Ogre::Entity* entity, - const SubMeshOps::PegOptions& opts, - const std::string& baseName); }; #endif // PARTOPSMESH_H diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index 18bd429e..4acb12b0 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -7,37 +7,6 @@ #include #include -#include - -namespace { - -// A vertex key that survives cross-submesh comparison for boundary welding: -// quantised position (sufficient for coincident split-seam verts). Position is -// the only reliable identity across independently-rebuilt part submeshes. -struct PosKey { - int64_t x, y, z; - bool operator==(const PosKey& o) const { return x == o.x && y == o.y && z == o.z; } -}; -struct PosKeyHash { - size_t operator()(const PosKey& k) const - { - uint64_t h = 1469598103934665603ULL; - for (int64_t c : {k.x, k.y, k.z}) { - h ^= static_cast(c); - h *= 1099511628211ULL; - } - return static_cast(h); - } -}; -PosKey posKeyOf(const Ogre::Vector3& p, double scale) -{ - return PosKey{static_cast(std::llround(double(p.x) * scale)), - static_cast(std::llround(double(p.y) * scale)), - static_cast(std::llround(double(p.z) * scale))}; -} - -} // namespace - std::vector SubMeshOps::groupFacesByLabel(const std::vector& faceLabels) { @@ -559,622 +528,3 @@ int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) sub.faces.clear(); return caps; } - -SubMeshOps::BoundaryPlane -SubMeshOps::estimateBoundaryPlane(const std::vector& partA, - const std::vector& partB, float weldTol) -{ - BoundaryPlane plane; - - // Boundary = vertices of A that are coincident (within weldTol) with a - // vertex of B — the seam the split duplicated. - const double scale = weldTol > 0.0f ? 1.0 / static_cast(weldTol) : 1e4; - std::unordered_map bKeys; - for (const auto& sm : partB) - for (const auto& v : sm.vertices) - bKeys[posKeyOf(v.position, scale)] += 1; - - std::vector pts; - for (const auto& sm : partA) - for (const auto& v : sm.vertices) { - if (bKeys.count(posKeyOf(v.position, scale))) - pts.push_back(v.position); - } - - if (pts.size() < 8) { - plane.reason = QStringLiteral("boundary too small (%1 shared verts, need >= 8)") - .arg(pts.size()); - return plane; - } - - // Centroid + covariance -> best-fit plane via smallest-eigenvalue direction. - Ogre::Vector3 c = Ogre::Vector3::ZERO; - for (const auto& p : pts) - c += p; - c /= static_cast(pts.size()); - - double cov[6] = {0, 0, 0, 0, 0, 0}; // xx xy xz yy yz zz - for (const auto& p : pts) { - const double dx = p.x - c.x, dy = p.y - c.y, dz = p.z - c.z; - cov[0] += dx * dx; cov[1] += dx * dy; cov[2] += dx * dz; - cov[3] += dy * dy; cov[4] += dy * dz; cov[5] += dz * dz; - } - const double inv = 1.0 / static_cast(pts.size()); - for (double& v : cov) - v *= inv; - - Ogre::Matrix3 C(cov[0], cov[1], cov[2], - cov[1], cov[3], cov[4], - cov[2], cov[4], cov[5]); - Ogre::Vector3 eigvec[3]; - Ogre::Real eigval[3]; - C.EigenSolveSymmetric(eigval, eigvec); - - // Smallest eigenvalue → plane normal. Ogre returns ascending eigenvalues. - int smallest = 0; - for (int i = 1; i < 3; ++i) - if (eigval[i] < eigval[smallest]) - smallest = i; - int largest = 0; - for (int i = 1; i < 3; ++i) - if (eigval[i] > eigval[largest]) - largest = i; - - // Stability: the boundary must look planar (small normal-direction spread - // relative to in-plane spread). If the smallest eigenvalue isn't clearly - // separated from the largest, it's a blob, not a seam. - const double lnMin = std::max(0.0, static_cast(eigval[smallest])); - const double lnMax = std::max(1e-12, static_cast(eigval[largest])); - const double flatness = lnMin / lnMax; - - plane.center = c; // seam-vertex centroid — the true joint cross-section centre - - // Flatness gate uses the covariance best-fit plane (a genuine seam is a thin - // disc: smallest eigenvalue << largest). - if (flatness > 0.15) { - plane.reason = QStringLiteral("boundary not planar enough (flatness %1)") - .arg(flatness, 0, 'g', 3); - return plane; - } - - // Peg AXIS = the direction the two parts separate = normalize(centroidB − - // centroidA), NOT the covariance eigenvector. For an organic joint whose cut - // ring isn't a flat disc (a diagonal shoulder/hip seam), the smallest - // eigenvector can point sideways along the surface, which placed the peg on - // the outer face. The part-to-part axis is always the correct insertion - // direction. Fall back to the eigenvector normal only if the two part - // centroids coincide (degenerate). - Ogre::Vector3 cA = Ogre::Vector3::ZERO, cB = Ogre::Vector3::ZERO; - size_t na = 0, nb = 0; - for (const auto& sm : partA) for (const auto& v : sm.vertices) { cA += v.position; ++na; } - for (const auto& sm : partB) for (const auto& v : sm.vertices) { cB += v.position; ++nb; } - Ogre::Vector3 axis = eigvec[smallest].normalisedCopy(); - if (na && nb) { - cA /= float(na); cB /= float(nb); - const Ogre::Vector3 partAxis = cB - cA; - if (partAxis.squaredLength() > 1e-12f) - axis = partAxis.normalisedCopy(); - } - plane.normal = axis; - - // In-plane radius: RMS distance to centroid projected off the (part) axis. - double r2 = 0.0; - for (const auto& p : pts) { - Ogre::Vector3 d = p - c; - Ogre::Vector3 inPlane = d - plane.normal * d.dotProduct(plane.normal); - r2 += inPlane.squaredLength(); - } - plane.radius = std::sqrt(r2 / static_cast(pts.size())); - - if (!(plane.radius > 0.0f)) { - plane.reason = QStringLiteral("degenerate boundary radius"); - return plane; - } - plane.stable = true; - return plane; -} - -namespace { - -// Append a closed cylinder (both caps) to `sub`, axis = `axis` (unit), -// centered at `base` and extending `depth` along +axis. Radius `r`, -// `segments` around. Adds vertices + triangles; leaves faces triangle-only. -void appendCylinder(EditableSubMesh& sub, const Ogre::Vector3& base, - const Ogre::Vector3& axis, float r, float depth, int segments) -{ - // Build an orthonormal frame around the axis. - Ogre::Vector3 up = std::fabs(axis.y) < 0.9f ? Ogre::Vector3::UNIT_Y : Ogre::Vector3::UNIT_X; - Ogre::Vector3 u = axis.crossProduct(up).normalisedCopy(); - Ogre::Vector3 w = axis.crossProduct(u).normalisedCopy(); - const Ogre::Vector3 top = base + axis * depth; - - const unsigned int startV = static_cast(sub.vertices.size()); - auto addVert = [&](const Ogre::Vector3& p, const Ogre::Vector3& n) { - EditableVertex v; - v.position = p; - v.normal = n; - v.hasNormal = true; - sub.vertices.push_back(v); - return static_cast(sub.vertices.size() - 1); - }; - auto addTri = [&](unsigned int a, unsigned int b, unsigned int c) { - EditableTriangle t; - t.indices[0] = a; t.indices[1] = b; t.indices[2] = c; - sub.triangles.push_back(t); - }; - - std::vector ringBase(segments), ringTop(segments); - for (int i = 0; i < segments; ++i) { - const float a = 2.0f * Ogre::Math::PI * float(i) / float(segments); - const Ogre::Vector3 radial = (u * std::cos(a) + w * std::sin(a)); - ringBase[i] = addVert(base + radial * r, radial); - ringTop[i] = addVert(top + radial * r, radial); - } - for (int i = 0; i < segments; ++i) { - const int j = (i + 1) % segments; - addTri(ringBase[i], ringBase[j], ringTop[j]); - addTri(ringBase[i], ringTop[j], ringTop[i]); - } - // Caps. - const unsigned int cBase = addVert(base, -axis); - const unsigned int cTop = addVert(top, axis); - for (int i = 0; i < segments; ++i) { - const int j = (i + 1) % segments; - addTri(cBase, ringBase[j], ringBase[i]); - addTri(cTop, ringTop[i], ringTop[j]); - } - (void)startV; -} - -} // namespace - -int SubMeshOps::buildAlignmentPegs(const BoundaryPlane& plane, const PegOptions& opts, - EditableSubMesh& outMale, EditableSubMesh& outSocket) -{ - if (!plane.stable) - return 0; - if (opts.maxPegsPerBoundary <= 0 || !(opts.pegRadius > 0.0f)) - return 0; - - // How many pegs actually fit inside the boundary ring without overlapping. - // Place them on a ring at ~half the boundary radius. - const float placeRadius = plane.radius * 0.5f; - const float pegR = opts.pegRadius; - const float socketR = opts.pegRadius + opts.clearance; - int nPegs = opts.maxPegsPerBoundary; - if (placeRadius < pegR * 1.5f) - nPegs = 1; // boundary too small for a ring; one central peg - - // In-plane frame. - Ogre::Vector3 up = std::fabs(plane.normal.y) < 0.9f ? Ogre::Vector3::UNIT_Y - : Ogre::Vector3::UNIT_X; - Ogre::Vector3 u = plane.normal.crossProduct(up).normalisedCopy(); - Ogre::Vector3 w = plane.normal.crossProduct(u).normalisedCopy(); - - int made = 0; - for (int i = 0; i < nPegs; ++i) { - Ogre::Vector3 center = plane.center; - if (nPegs > 1) { - const float a = 2.0f * Ogre::Math::PI * float(i) / float(nPegs); - center += (u * std::cos(a) + w * std::sin(a)) * placeRadius; - } - // Male peg protrudes from the boundary along +normal; socket cutter - // sinks along the same axis but starts slightly behind the plane so it - // fully overlaps the mating solid. - appendCylinder(outMale, center, plane.normal, pegR, opts.pegDepth, opts.radialSegments); - appendCylinder(outSocket, center - plane.normal * (opts.clearance), - plane.normal, socketR, opts.pegDepth + opts.clearance, opts.radialSegments); - ++made; - } - - if (made > 0) { - outMale.materialName = "connector_male"; - outSocket.materialName = "connector_socket"; - } - return made; -} - -namespace { -// Append `src`'s vertices + triangles onto `dst` (offsetting the indices by -// dst's current vertex count). Used to merge a peg cylinder into a part. -void appendGeometry(EditableSubMesh& dst, const EditableSubMesh& src) -{ - const unsigned int base = static_cast(dst.vertices.size()); - dst.vertices.insert(dst.vertices.end(), src.vertices.begin(), src.vertices.end()); - for (const EditableTriangle& t : src.triangles) { - EditableTriangle nt; - nt.indices[0] = t.indices[0] + base; - nt.indices[1] = t.indices[1] + base; - nt.indices[2] = t.indices[2] + base; - dst.triangles.push_back(nt); - } -} - -// Give every vertex in `sub` that lacks bone weights the bone assignments of its -// nearest vertex in `source` — so connector geometry (peg / socket collar) on a -// SKINNED part rigidly follows the part it attaches to instead of collapsing to -// the skeleton origin (a vertex with no weights binds to bone 0 at weight 0). -// No-op when the source part has no weights (static mesh). -void inheritNearestBoneWeights(EditableSubMesh& sub, const EditableSubMesh& source) -{ - bool sourceSkinned = false; - for (const EditableVertex& v : source.vertices) - if (!v.boneAssignments.empty()) { sourceSkinned = true; break; } - if (!sourceSkinned || source.vertices.empty()) - return; - for (EditableVertex& v : sub.vertices) { - if (!v.boneAssignments.empty()) - continue; - const EditableVertex* best = nullptr; - float bestD = std::numeric_limits::max(); - for (const EditableVertex& sv : source.vertices) { - if (sv.boneAssignments.empty()) - continue; - const float d = sv.position.squaredDistance(v.position); - if (d < bestD) { bestD = d; best = &sv; } - } - if (best) - v.boneAssignments = best->boneAssignments; - } -} - -// Append a short, thick RING (annular collar) at a socket mouth to `sub`, -// centered at `center`, in the plane normal to `axis`. Inner radius = `r` -// (the socket bore), outer = 1.35·r, thickness `t` along +axis. Purely a -// visible red marker for the female side; renders as a flat washer. -void appendSocketCollar(EditableSubMesh& sub, const Ogre::Vector3& center, - const Ogre::Vector3& axis, float r, int segments) -{ - const Ogre::Vector3 n = axis.normalisedCopy(); - const float rOuter = r * 1.35f; - const float t = r * 0.12f; // shallow washer thickness - Ogre::Vector3 up = std::fabs(n.y) < 0.9f ? Ogre::Vector3::UNIT_Y : Ogre::Vector3::UNIT_X; - Ogre::Vector3 u = n.crossProduct(up).normalisedCopy(); - Ogre::Vector3 w = n.crossProduct(u).normalisedCopy(); - const Ogre::Vector3 front = center + n * (t * 0.5f); - const Ogre::Vector3 back = center - n * (t * 0.5f); - - auto addVert = [&](const Ogre::Vector3& p, const Ogre::Vector3& nrm) { - EditableVertex v; v.position = p; v.normal = nrm; v.hasNormal = true; - sub.vertices.push_back(v); - return static_cast(sub.vertices.size() - 1); - }; - auto addTri = [&](unsigned int a, unsigned int b, unsigned int c) { - EditableTriangle tri; tri.indices[0] = a; tri.indices[1] = b; tri.indices[2] = c; - sub.triangles.push_back(tri); - }; - std::vector fi(segments), fo(segments), bi(segments), bo(segments); - for (int i = 0; i < segments; ++i) { - const float a = 2.0f * Ogre::Math::PI * float(i) / float(segments); - const Ogre::Vector3 rad = (u * std::cos(a) + w * std::sin(a)); - fi[i] = addVert(front + rad * r, n); - fo[i] = addVert(front + rad * rOuter, n); - bi[i] = addVert(back + rad * r, -n); - bo[i] = addVert(back + rad * rOuter, -n); - } - for (int i = 0; i < segments; ++i) { - const int j = (i + 1) % segments; - // front face (facing +n) - addTri(fi[i], fo[i], fo[j]); addTri(fi[i], fo[j], fi[j]); - // back face (facing -n) - addTri(bi[i], bo[j], bo[i]); addTri(bi[i], bi[j], bo[j]); - // outer wall - addTri(fo[i], bo[i], bo[j]); addTri(fo[i], bo[j], fo[j]); - // inner wall - addTri(fi[i], bi[j], bi[i]); addTri(fi[i], fi[j], bi[j]); - } -} - -// Reproduce the exact peg-ring centers that buildAlignmentPegs() places, so the -// SOCKET boolean cutters line up 1:1 with the male pegs. `made` is the peg count -// buildAlignmentPegs actually produced (it clamps a too-small boundary to 1). -std::vector pegRingCenters(const SubMeshOps::BoundaryPlane& plane, - const SubMeshOps::PegOptions& opts, int made) -{ - std::vector centers; - if (made <= 0) - return centers; - const float placeRadius = plane.radius * 0.5f; - Ogre::Vector3 up = std::fabs(plane.normal.y) < 0.9f ? Ogre::Vector3::UNIT_Y - : Ogre::Vector3::UNIT_X; - Ogre::Vector3 u = plane.normal.crossProduct(up).normalisedCopy(); - Ogre::Vector3 w = plane.normal.crossProduct(u).normalisedCopy(); - for (int i = 0; i < made; ++i) { - Ogre::Vector3 center = plane.center; - if (made > 1) { - const float a = 2.0f * Ogre::Math::PI * float(i) / float(made); - center += (u * std::cos(a) + w * std::sin(a)) * placeRadius; - } - centers.push_back(center); - } - return centers; -} - -// Convert an EditableSubMesh's triangle soup into a Manifold solid. Position-only -// (Manifold does its own vertex welding by geometric position), which is all the -// boolean needs — attributes are re-derived after by nearest-source lookup. -manifold::Manifold toManifold(const EditableSubMesh& sub) -{ - manifold::MeshGL m; - m.numProp = 3; - m.vertProperties.reserve(sub.vertices.size() * 3); - for (const EditableVertex& v : sub.vertices) { - m.vertProperties.push_back(v.position.x); - m.vertProperties.push_back(v.position.y); - m.vertProperties.push_back(v.position.z); - } - m.triVerts.reserve(sub.triangles.size() * 3); - for (const EditableTriangle& t : sub.triangles) { - m.triVerts.push_back(t.indices[0]); - m.triVerts.push_back(t.indices[1]); - m.triVerts.push_back(t.indices[2]); - } - return manifold::Manifold(m); -} - -// Rebuild an EditableSubMesh from a Manifold result, re-deriving per-vertex -// attributes (normal/uv/colour/bone weights) from the ORIGINAL sub by nearest -// source vertex — so verts the boolean left untouched keep their exact data and -// newly-created socket-wall verts inherit their closest neighbour's attributes. -void fromManifold(const manifold::Manifold& man, const EditableSubMesh& original, - EditableSubMesh& out) -{ - manifold::MeshGL result = man.GetMeshGL(); - out.vertices.clear(); - out.triangles.clear(); - out.vertices.reserve(result.NumVert()); - - // Brute-force nearest source vertex (part vertex counts are small — a few - // thousand at most — and this runs once per socket cut). - auto nearestSource = [&](const Ogre::Vector3& p) -> const EditableVertex* { - const EditableVertex* best = nullptr; - float bestD = std::numeric_limits::max(); - for (const EditableVertex& sv : original.vertices) { - const float d = sv.position.squaredDistance(p); - if (d < bestD) { bestD = d; best = &sv; } - } - return best; - }; - - const uint32_t stride = result.numProp; - for (uint32_t i = 0; i < result.NumVert(); ++i) { - EditableVertex v; - v.position = Ogre::Vector3(result.vertProperties[i * stride + 0], - result.vertProperties[i * stride + 1], - result.vertProperties[i * stride + 2]); - if (const EditableVertex* src = nearestSource(v.position)) { - EditableVertex copy = *src; - copy.position = v.position; // keep the boolean's exact position - out.vertices.push_back(copy); - } else { - out.vertices.push_back(v); - } - } - for (size_t i = 0; i + 2 < result.triVerts.size(); i += 3) { - EditableTriangle t; - t.indices[0] = result.triVerts[i + 0]; - t.indices[1] = result.triVerts[i + 1]; - t.indices[2] = result.triVerts[i + 2]; - out.triangles.push_back(t); - } - out.materialName = original.materialName; -} - -// Cut real cylindrical socket cavities into `part` — one per peg center — via a -// robust mesh boolean. Each cutter is a cylinder of radius `r`, length `depth`, -// axis `-axis` (into the part), starting slightly proud of the seam so it fully -// overlaps the solid. Falls back to leaving `part` untouched if the boolean -// throws (degenerate input) — the male peg still guides assembly. -void subtractSockets(EditableSubMesh& part, const std::vector& centers, - const Ogre::Vector3& axis, float r, float depth, int segments) -{ - if (centers.empty() || part.triangles.empty()) - return; - try { - manifold::Manifold solid = toManifold(part); - if (solid.IsEmpty()) - return; - const Ogre::Vector3 unit = axis.normalisedCopy(); - // Manifold::Cylinder is built along +Z from the origin; rotate/translate - // each cutter so its +Z maps to -unit (into the part) starting proud of - // the seam. We approximate the transform with Manifold's own helpers by - // building the cylinder then applying a 4x4. - for (const Ogre::Vector3& c : centers) { - // A cylinder from the origin along +Z, height `depth`, radius r. - manifold::Manifold cutter = - manifold::Manifold::Cylinder(depth, r, r, segments, false); - // Orient +Z -> -unit. Build a rotation matrix from basis vectors. - const Ogre::Vector3 zdir = -unit; - Ogre::Vector3 upv = std::fabs(zdir.y) < 0.9f ? Ogre::Vector3::UNIT_Y - : Ogre::Vector3::UNIT_X; - Ogre::Vector3 xdir = upv.crossProduct(zdir).normalisedCopy(); - Ogre::Vector3 ydir = zdir.crossProduct(xdir).normalisedCopy(); - // Cutter starts barely proud of the seam (along +unit) so it fully - // spans into the part along -unit. - const Ogre::Vector3 base = c + unit * 0.001f; - // Column-major 3x4 affine for Manifold::Transform (mat3x4). - manifold::mat3x4 tf; - tf[0] = manifold::vec3(xdir.x, xdir.y, xdir.z); - tf[1] = manifold::vec3(ydir.x, ydir.y, ydir.z); - tf[2] = manifold::vec3(zdir.x, zdir.y, zdir.z); - tf[3] = manifold::vec3(base.x, base.y, base.z); - cutter = cutter.Transform(tf); - solid = solid - cutter; - } - if (solid.IsEmpty()) - return; - EditableSubMesh cut; - fromManifold(solid, part, cut); - if (!cut.triangles.empty()) - part = std::move(cut); - } catch (const std::exception&) { - // Boolean failed on degenerate input — leave the part unchanged. - } -} - -} // namespace - -SubMeshOps::PrintPrepResult -SubMeshOps::preparePrintPegs(const std::vector& subMeshes, - const PegOptions& opts, const std::vector& partNames) -{ - PrintPrepResult out; - if (subMeshes.size() < 2) { - out.error = QStringLiteral("need at least two parts to add alignment pegs"); - return out; - } - out.subMeshes = subMeshes; // start from the parts; merge pegs in below. - out.partNames = partNames; - out.partNames.resize(subMeshes.size()); - - // Each connector is merged directly INTO the part it belongs to (male peg → - // its source part, female socket cavity + collar → the mating part) so every - // part stays ONE self-contained printable mesh in its own material — no - // separate connector submeshes. - - // Close each part's OPEN cut face first (a split leaves it hollow) so every - // part is a watertight printable solid and the pegs attach to a real - // surface. Boundary planes are still estimated from the ORIGINAL (uncapped) - // submeshes below, so the coincident-seam detection is unaffected by the cap. - for (auto& part : out.subMeshes) - out.cappedParts += (capOpenBoundaries(part) > 0) ? 1 : 0; - auto nameOf = [&](int i) -> QString { - return (i >= 0 && i < static_cast(out.partNames.size()) && !out.partNames[i].isEmpty()) - ? out.partNames[i] : QStringLiteral("part%1").arg(i); - }; - - // For every unordered pair of parts, estimate the shared boundary; where it - // is stable, build a male peg (→ partA) + socket (→ partB) and merge each - // into its part as extra geometry. `estimateBoundaryPlane` works on submesh - // VECTORS, so wrap each part in a one-element vector. - const int n = static_cast(subMeshes.size()); - for (int a = 0; a < n; ++a) { - for (int b = a + 1; b < n; ++b) { - PegBoundary rec; - rec.partA = a; rec.partB = b; - rec.nameA = nameOf(a); rec.nameB = nameOf(b); - - BoundaryPlane plane = estimateBoundaryPlane({ subMeshes[a] }, { subMeshes[b] }); - if (!plane.stable) { - rec.reason = plane.reason.isEmpty() - ? QStringLiteral("no stable shared boundary") : plane.reason; - out.boundaries.push_back(rec); - continue; - } - // The best-fit normal's SIGN is arbitrary (an eigenvector), but the - // male peg extrudes along +normal — so orient it from the MALE part - // (A) toward the FEMALE part (B), using their body centroids, or the - // peg would protrude into the wrong part (CodeRabbit/Codex). - { - Ogre::Vector3 cA = Ogre::Vector3::ZERO, cB = Ogre::Vector3::ZERO; - size_t na = 0, nb = 0; - for (const auto& sm : { subMeshes[a] }) for (const auto& v : sm.vertices) { cA += v.position; ++na; } - for (const auto& sm : { subMeshes[b] }) for (const auto& v : sm.vertices) { cB += v.position; ++nb; } - if (na && nb) { - cA /= float(na); cB /= float(nb); - if (plane.normal.dotProduct(cB - cA) < 0.0f) - plane.normal = -plane.normal; - } - } - - // Adapt the peg size to THIS boundary so it always fits, regardless - // of the model's unit scale (the issue's fixed radius=1.5 is 80% of a - // unit-normalised character's diagonal — a giant blob). A peg radius - // is capped at 30% of the boundary ring radius, and the socket - // clearance / peg depth scale down with it (keeping their ratios to - // the user's request). The user's values are treated as an UPPER - // bound — a big model with a big boundary keeps them as-is. - PegOptions boundaryOpts = opts; - const float maxPegR = 0.30f * plane.radius; - if (maxPegR > 1e-4f && boundaryOpts.pegRadius > maxPegR) { - const float scale = maxPegR / boundaryOpts.pegRadius; - boundaryOpts.pegRadius = maxPegR; - boundaryOpts.pegDepth *= scale; - boundaryOpts.clearance *= scale; - } - - // Bound the socket DEPTH so it never punches through the thinner of - // the two mating parts. Measure each part's extent ALONG the peg axis - // and cap depth at 35% of the smaller — otherwise a deep default peg - // (pegDepth=4) bores clean through a thin torso/limb, showing as a - // dark tunnel. The socket sinks pegDepth+clearance, so bound on that. - { - auto extentAlong = [&](int idx) { - float mn = 1e30f, mx = -1e30f; - for (const auto& v : subMeshes[idx].vertices) { - const float d = v.position.dotProduct(plane.normal); - mn = std::min(mn, d); mx = std::max(mx, d); - } - return (mx > mn) ? (mx - mn) : 0.0f; - }; - const float thin = std::min(extentAlong(a), extentAlong(b)); - if (thin > 1e-4f) { - const float maxSink = 0.35f * thin; // socket total sink - const float sink = boundaryOpts.pegDepth + boundaryOpts.clearance; - if (sink > maxSink) { - const float ds = maxSink / sink; - boundaryOpts.pegDepth *= ds; - boundaryOpts.clearance *= ds; - } - } - } - - // Keep the peg count modest — a single centered peg unless the - // boundary ring is clearly big enough for a spaced pair/trio (each - // extra peg is another pit in the part). This avoids the torso - // sprouting three large sockets around one joint. - { - const float ringToPeg = boundaryOpts.pegRadius > 1e-5f - ? plane.radius / boundaryOpts.pegRadius : 0.0f; - if (ringToPeg < 6.0f) boundaryOpts.maxPegsPerBoundary = - std::min(boundaryOpts.maxPegsPerBoundary, 1); - else if (ringToPeg < 10.0f) boundaryOpts.maxPegsPerBoundary = - std::min(boundaryOpts.maxPegsPerBoundary, 2); - } - - EditableSubMesh male, socketUnused; - const int made = buildAlignmentPegs(plane, boundaryOpts, male, socketUnused); - if (made <= 0) { - rec.reason = QStringLiteral("boundary too small for a peg"); - out.boundaries.push_back(rec); - continue; - } - // Merge the male peg directly INTO its source part (A) so each part - // is one self-contained printable object that carries its own peg — - // it renders in the part's own material, not a separate connector - // submesh. On a skinned mesh the peg inherits part A's nearest bone - // weights so it moves with that part (not the skeleton origin). - inheritNearestBoneWeights(male, subMeshes[a]); - appendGeometry(out.subMeshes[a], male); - // Cut a real cylindrical SOCKET CAVITY into partB for each peg via a - // robust mesh boolean (Manifold), so the male peg actually inserts — - // not a solid cylinder added as fake geometry. The socket is the peg - // + clearance, sunk slightly behind the seam so it fully overlaps B. - const std::vector pegCenters = - pegRingCenters(plane, boundaryOpts, made); - const float socketR = boundaryOpts.pegRadius + boundaryOpts.clearance; - subtractSockets(out.subMeshes[b], pegCenters, plane.normal, socketR, - boundaryOpts.pegDepth + boundaryOpts.clearance, - boundaryOpts.radialSegments); - // A shallow collar ring at each socket mouth (a raised lip around the - // bore). Merged INTO part B so the female side is one solid too; - // inherits part B's bone weights. Built into a temp first for that. - EditableSubMesh collars; - for (const Ogre::Vector3& pc : pegCenters) - appendSocketCollar(collars, pc, plane.normal, socketR, - boundaryOpts.radialSegments); - inheritNearestBoneWeights(collars, subMeshes[b]); - appendGeometry(out.subMeshes[b], collars); - rec.pegged = true; - rec.pegCount = made; - out.boundaries.push_back(rec); - ++out.peggedBoundaries; - out.totalPegs += made; - } - } - - out.ok = true; - if (out.peggedBoundaries == 0) - out.error = QStringLiteral("no stable boundary found — no pegs added"); - return out; -} diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h index 5a1ac272..5f5e538c 100644 --- a/src/SubMeshOps.h +++ b/src/SubMeshOps.h @@ -14,7 +14,7 @@ * * PartOps turns AI mesh segmentation (`MeshSegmenter`) into real authoring * operations: split a fused mesh into per-part submeshes, explode those into - * separate scene nodes, join them back, and add 3D-print alignment pegs. + * separate scene nodes, and join them back. * * Everything here operates on `std::vector` — the same * attribute-complete editable representation `EditableMesh` loads from an @@ -179,85 +179,6 @@ class SubMeshOps * space. Edits `sub` in place; returns the number of caps (loops) filled. * Deterministic; pure-data. Skips loops shorter than 3 edges. */ static int capOpenBoundaries(EditableSubMesh& sub); - - // ------------------------------------------------------------------------- - // Print-split alignment pegs (Slice D #863) - // ------------------------------------------------------------------------- - - struct PegOptions { - float clearance = 0.20f; ///< socket radius = pegRadius + clearance. - float pegRadius = 1.50f; ///< model units. - float pegDepth = 4.00f; ///< how far the peg protrudes / socket sinks. - int maxPegsPerBoundary = 3; - int radialSegments = 16; ///< cylinder tessellation. - }; - - /** A boundary plane estimated between two parts: the shared/coincident - * vertices' centroid + best-fit normal (via covariance). `stable` is - * false when the boundary is too small or too noisy to place pegs. */ - struct BoundaryPlane { - Ogre::Vector3 center = Ogre::Vector3::ZERO; - Ogre::Vector3 normal = Ogre::Vector3::UNIT_Y; - float radius = 0.0f; ///< extent of the boundary ring in-plane. - bool stable = false; - QString reason; ///< why unstable (when !stable). - }; - - /** Estimate the boundary plane between two parts from vertices that are - * coincident (within `weldTol`) across the two submesh sets — the seam - * left by a split. Needs >= 8 coincident points and a covariance whose - * smallest eigenvalue is well-separated (a real plane, not a blob) to be - * `stable`. Pure-data. */ - static BoundaryPlane estimateBoundaryPlane(const std::vector& partA, - const std::vector& partB, - float weldTol = 1e-4f); - - /** Build matching male-peg (added to `outMale`) and female-socket-cutter - * (added to `outSocket`) cylinder submeshes on the given boundary plane. - * Pegs are placed on a ring inside the boundary radius, up to - * `maxPegsPerBoundary`. The socket cutter is the peg + clearance; the - * adapter decides whether to boolean-subtract or just group it (this MVP - * emits it as a named submesh — no boolean, per epic scope). Returns the - * number of pegs generated (0 when the plane is unstable). Pure geometry. */ - static int buildAlignmentPegs(const BoundaryPlane& plane, const PegOptions& opts, - EditableSubMesh& outMale, EditableSubMesh& outSocket); - - /** One boundary the print-prep pass considered. */ - struct PegBoundary { - int partA = -1; ///< index into the input submeshes. - int partB = -1; - QString nameA, nameB; ///< the parts' display names (for messages). - bool pegged = false; ///< true when pegs were placed. - int pegCount = 0; - QString reason; ///< why skipped (when !pegged). - }; - - struct PrintPrepResult { - bool ok = false; - QString error; - /** The new submesh layout: the input parts, each with its male peg OR - * socket merged in as extra geometry, plus any parts unchanged. */ - std::vector subMeshes; - std::vector partNames; ///< parallel to subMeshes. - std::vector boundaries; ///< every pair considered (diag). - int peggedBoundaries = 0; - int totalPegs = 0; - int cappedParts = 0; ///< parts whose open cut face was closed. - }; - - /** Prepare a split mesh for 3D printing (Slice D #863). For EVERY pair of - * input submeshes that share a STABLE planar boundary (the seam a split - * left — coincident verts across the pair, via `estimateBoundaryPlane`), - * generate matching cylindrical pegs: the MALE peg is merged into `partA` - * and the female SOCKET-cutter into `partB` (each as extra geometry with a - * `connector_male`/`connector_socket` material), so each part carries its - * own connector and stays one printable object. Tiny / non-planar - * boundaries are skipped with a per-pair `reason` (never fails the whole - * op). `partNames` (optional, parallel to `subMeshes`) is used for the - * boundary report + connector naming. Deterministic; pure-data. */ - static PrintPrepResult preparePrintPegs(const std::vector& subMeshes, - const PegOptions& opts, - const std::vector& partNames = {}); }; #endif // SUBMESHOPS_H diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index b5aec90f..ab6a0efc 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -366,190 +366,6 @@ TEST(SubMeshOpsTest, ExplodeOffsetsPushOutwardFromCenter) EXPECT_NEAR(offs[1].length(), 1.0f, 1e-5f); } -TEST(SubMeshOpsTest, BoundaryPlaneEstimatedFromSharedSeam) -{ - // Part A and B share a planar seam at x=0 (the YZ plane): 9 coincident - // verts. A extends to -x, B to +x. Estimated normal ≈ ±X. - EditableSubMesh a, b; - for (int y = 0; y < 3; ++y) - for (int z = 0; z < 3; ++z) { - a.vertices.push_back(vtx(0, float(y), float(z))); // seam - b.vertices.push_back(vtx(0, float(y), float(z))); // seam (coincident) - } - a.vertices.push_back(vtx(-1, 1, 1)); // A body - b.vertices.push_back(vtx(1, 1, 1)); // B body - // need a triangle so it's a valid submesh (not required by the estimator, - // but keeps the fixture honest). - addTri(a, 0, 1, 2); - addTri(b, 0, 1, 2); - - auto plane = SubMeshOps::estimateBoundaryPlane({a}, {b}); - ASSERT_TRUE(plane.stable) << plane.reason.toStdString(); - EXPECT_NEAR(std::fabs(plane.normal.x), 1.0f, 1e-3f); - EXPECT_NEAR(plane.center.x, 0.0f, 1e-4f); - EXPECT_GT(plane.radius, 0.0f); -} - -TEST(SubMeshOpsTest, BoundaryPlaneRejectsTinyBoundary) -{ - // Only 3 shared verts → below the 8-vertex minimum. - EditableSubMesh a, b; - for (int i = 0; i < 3; ++i) { - a.vertices.push_back(vtx(0, float(i), 0)); - b.vertices.push_back(vtx(0, float(i), 0)); - } - auto plane = SubMeshOps::estimateBoundaryPlane({a}, {b}); - EXPECT_FALSE(plane.stable); - EXPECT_FALSE(plane.reason.isEmpty()); -} - -TEST(SubMeshOpsTest, AlignmentPegsGeneratedOnStablePlane) -{ - SubMeshOps::BoundaryPlane plane; - plane.center = Ogre::Vector3(0, 0, 0); - plane.normal = Ogre::Vector3::UNIT_X; - plane.radius = 10.0f; - plane.stable = true; - - SubMeshOps::PegOptions opts; // defaults: r=1.5, depth=4, maxPegs=3 - EditableSubMesh male, socket; - int n = SubMeshOps::buildAlignmentPegs(plane, opts, male, socket); - EXPECT_EQ(n, 3); - EXPECT_FALSE(male.triangles.empty()); - EXPECT_FALSE(socket.triangles.empty()); - EXPECT_EQ(male.materialName, "connector_male"); - EXPECT_EQ(socket.materialName, "connector_socket"); - // Socket radius > peg radius (clearance) → socket cylinder verts spread wider. - // Cheap check: socket has same vertex count structure as male (same segments). - EXPECT_EQ(male.vertices.size(), socket.vertices.size()); -} - -TEST(SubMeshOpsTest, AlignmentPegsSkippedOnUnstablePlane) -{ - SubMeshOps::BoundaryPlane plane; // stable=false by default - SubMeshOps::PegOptions opts; - EditableSubMesh male, socket; - EXPECT_EQ(SubMeshOps::buildAlignmentPegs(plane, opts, male, socket), 0); - EXPECT_TRUE(male.triangles.empty()); -} - -// ---- preparePrintPegs (Slice D #863) -------------------------------------- - -namespace { -// Two parts sharing a stable planar seam at x=0 (16 coincident verts on a 4×4 -// grid so the boundary radius is comfortably > peg radius). A extends to -x, -// B to +x. Each part has one triangle so it's a valid submesh. -void twoPartsWithSeam(EditableSubMesh& a, EditableSubMesh& b) -{ - a = EditableSubMesh(); b = EditableSubMesh(); - a.materialName = "Body"; b.materialName = "Body"; - for (int y = 0; y < 4; ++y) - for (int z = 0; z < 4; ++z) { - a.vertices.push_back(vtx(0, float(y), float(z))); - b.vertices.push_back(vtx(0, float(y), float(z))); - } - a.vertices.push_back(vtx(-2, 1.5f, 1.5f)); - b.vertices.push_back(vtx(2, 1.5f, 1.5f)); - addTri(a, 0, 1, 2); - addTri(b, 0, 1, 2); -} -} // namespace - -TEST(SubMeshOpsTest, PreparePrintPegsAddsMaleAndSocket) -{ - EditableSubMesh a, b; - twoPartsWithSeam(a, b); - const size_t aVerts0 = a.vertices.size(), bVerts0 = b.vertices.size(); - - SubMeshOps::PegOptions opts; - opts.pegRadius = 0.4f; opts.pegDepth = 1.0f; opts.maxPegsPerBoundary = 3; - auto r = SubMeshOps::preparePrintPegs({a, b}, opts, {"torso", "left_leg"}); - ASSERT_TRUE(r.ok) << r.error.toStdString(); - EXPECT_EQ(r.peggedBoundaries, 1); - EXPECT_GT(r.totalPegs, 0); - // Each connector is merged INTO its part — NO separate connector submeshes. - // The result keeps exactly the two input parts, each in its own material. - ASSERT_EQ(r.subMeshes.size(), 2u); - ASSERT_EQ(r.partNames.size(), 2u); - EXPECT_EQ(r.subMeshes[0].materialName, "Body"); - EXPECT_EQ(r.subMeshes[1].materialName, "Body"); - EXPECT_EQ(r.partNames[0].toStdString(), "torso"); - EXPECT_EQ(r.partNames[1].toStdString(), "left_leg"); - // Part A (male side) gained the peg's extra geometry. - EXPECT_GT(r.subMeshes[0].vertices.size(), aVerts0); - // Part B (female side) had a real socket cavity cut into it AND a collar - // merged in, so its vertex count changed from the input. - EXPECT_NE(r.subMeshes[1].vertices.size(), bVerts0); - // The boundary report is populated with both part names. - ASSERT_EQ(r.boundaries.size(), 1u); - EXPECT_TRUE(r.boundaries[0].pegged); - EXPECT_EQ(r.boundaries[0].nameA.toStdString(), "torso"); - EXPECT_EQ(r.boundaries[0].nameB.toStdString(), "left_leg"); -} - -TEST(SubMeshOpsTest, PreparePrintPegsConnectorsInheritBoneWeights) -{ - // A SKINNED two-part input: every part vertex is weighted to a bone. The - // connector geometry (peg + collar + boolean cavity walls) starts weightless - // and is merged INTO its part, so preparePrintPegs must inherit the nearest - // part vertex's weights or those verts collapse to the skeleton origin. - EditableSubMesh a, b; - twoPartsWithSeam(a, b); - for (auto* part : {&a, &b}) { - const unsigned short bone = (part == &a) ? 3 : 7; - for (auto& v : part->vertices) { - EditableBoneAssignment ba; ba.boneIndex = bone; ba.weight = 1.0f; - v.boneAssignments.push_back(ba); - } - } - SubMeshOps::PegOptions opts; - opts.pegRadius = 0.4f; opts.pegDepth = 1.0f; opts.maxPegsPerBoundary = 3; - auto r = SubMeshOps::preparePrintPegs({a, b}, opts, {"torso", "left_leg"}); - ASSERT_TRUE(r.ok) << r.error.toStdString(); - ASSERT_EQ(r.subMeshes.size(), 2u); - // Connectors merged into the parts: EVERY vertex of both parts is weighted - // (the male peg + collar + socket-cavity walls all inherited a part bone). - for (size_t s : {size_t(0), size_t(1)}) { - ASSERT_FALSE(r.subMeshes[s].vertices.empty()); - for (const auto& v : r.subMeshes[s].vertices) - EXPECT_FALSE(v.boneAssignments.empty()) - << "part submesh " << s << " has a weightless (connector) vertex"; - } - // Part A's verts stay on bone 3 (its own bone + the merged peg's inherited - // bone); part B's verts stay on bone 7. - for (const auto& v : r.subMeshes[0].vertices) - EXPECT_EQ(v.boneAssignments[0].boneIndex, 3) << "part A vertex not on bone 3"; - for (const auto& v : r.subMeshes[1].vertices) - EXPECT_EQ(v.boneAssignments[0].boneIndex, 7) << "part B vertex not on bone 7"; -} - -TEST(SubMeshOpsTest, PreparePrintPegsRejectsTinyBoundary) -{ - // Two parts that do NOT share enough coincident verts (< 8) → no stable - // boundary → no pegs, but the op succeeds with a per-pair reason. - EditableSubMesh a, b; - a.materialName = "Body"; b.materialName = "Body"; - a.vertices = {vtx(0,0,0), vtx(0,1,0), vtx(-1,0,0)}; - b.vertices = {vtx(5,0,0), vtx(5,1,0), vtx(6,0,0)}; // far away, no shared seam - addTri(a,0,1,2); addTri(b,0,1,2); - - auto r = SubMeshOps::preparePrintPegs({a, b}, SubMeshOps::PegOptions{}); - EXPECT_TRUE(r.ok); // never fails the whole op - EXPECT_EQ(r.peggedBoundaries, 0); - ASSERT_EQ(r.boundaries.size(), 1u); - EXPECT_FALSE(r.boundaries[0].pegged); - EXPECT_FALSE(r.boundaries[0].reason.isEmpty()); - EXPECT_FALSE(r.error.isEmpty()); // "no stable boundary found" -} - -TEST(SubMeshOpsTest, PreparePrintPegsNeedsTwoParts) -{ - EditableSubMesh a; a.vertices = {vtx(0,0,0), vtx(1,0,0), vtx(0,1,0)}; addTri(a,0,1,2); - auto r = SubMeshOps::preparePrintPegs({a}, SubMeshOps::PegOptions{}); - EXPECT_FALSE(r.ok); - EXPECT_FALSE(r.error.isEmpty()); -} - // ---- capOpenBoundaries (#863 close split cut face) ------------------------ TEST(SubMeshOpsTest, CapOpenBoundaryClosesHole) diff --git a/src/commands/AddPrintPegsCommand.cpp b/src/commands/AddPrintPegsCommand.cpp deleted file mode 100644 index 38ff8863..00000000 --- a/src/commands/AddPrintPegsCommand.cpp +++ /dev/null @@ -1,104 +0,0 @@ -#include "AddPrintPegsCommand.h" - -#include "Manager.h" -#include "PartOpsMesh.h" -#include "SelectionSet.h" -#include "SentryReporter.h" - -#include -#include -#include - -AddPrintPegsCommand::AddPrintPegsCommand(std::string entityName, - SubMeshOps::PegOptions opts, QUndoCommand* parent) - : QUndoCommand(parent) - , mEntityName(std::move(entityName)) - , mOpts(opts) -{ - setText(QStringLiteral("Add Print Alignment Pegs")); -} - -Ogre::Entity* AddPrintPegsCommand::resolveEntity() const -{ - Manager* mgr = Manager::getSingletonPtr(); - if (!mgr) - return nullptr; - for (Ogre::Entity* e : mgr->getEntities()) { - if (e && e->getMovableType() == "Entity" && e->getName() == mEntityName) - return e; - } - return nullptr; -} - -Ogre::Entity* AddPrintPegsCommand::swapEntityMesh(const Ogre::MeshPtr& mesh) -{ - Manager* mgr = Manager::getSingletonPtr(); - if (!mgr || !mesh) - return nullptr; - Ogre::Entity* cur = resolveEntity(); - if (!cur) - return nullptr; - Ogre::SceneNode* node = cur->getParentSceneNode(); - if (!node) - return nullptr; - - // Drop every selection reference before freeing the entity (SplitMeshCommand - // rationale: dangling sub-entity refs crash the next selection query). - if (auto* sel = SelectionSet::getSingleton()) { - mReselectNode = sel->contains(node) ? node : nullptr; - sel->clearList(); - } - node->detachObject(cur); - mgr->getSceneMgr()->destroyEntity(cur); - Ogre::Entity* ne = mgr->createEntity(node, mesh); - if (ne && mReselectNode) { - if (auto* sel = SelectionSet::getSingleton()) - sel->selectOne(node); - } - return ne; -} - -void AddPrintPegsCommand::redo() -{ - if (!mBuilt) { - mBuilt = true; - Ogre::Entity* entity = resolveEntity(); - if (!entity || !entity->getMesh()) { - mError = QStringLiteral("no entity to prep"); - return; - } - mOriginalMesh = entity->getMesh(); // resident for undo. - - PartOpsMesh::PrintPrepOutcome po = - PartOpsMesh::addPrintPegsToEntity(entity, mOpts, - mEntityName + std::string("_pegged")); - if (!po.ok) { - mError = po.error.isEmpty() ? QStringLiteral("print prep failed") : po.error; - return; - } - mPeggedMesh = po.mesh; - mPeggedBoundaries = po.peggedBoundaries; - mTotalPegs = po.totalPegs; - mWarnings = po.warnings; - } - - if (!mPeggedMesh) { - mOk = false; - return; // build failed on first redo; mError set. - } - Ogre::Entity* ne = swapEntityMesh(mPeggedMesh); - mOk = (ne != nullptr); - if (mOk) - SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.print_pegs"), - QStringLiteral("boundaries=%1 pegs=%2") - .arg(mPeggedBoundaries).arg(mTotalPegs)); - else if (mError.isEmpty()) - mError = QStringLiteral("failed to swap in pegged mesh"); -} - -void AddPrintPegsCommand::undo() -{ - if (!mOriginalMesh) - return; - swapEntityMesh(mOriginalMesh); -} diff --git a/src/commands/AddPrintPegsCommand.h b/src/commands/AddPrintPegsCommand.h deleted file mode 100644 index 1aa9f03b..00000000 --- a/src/commands/AddPrintPegsCommand.h +++ /dev/null @@ -1,64 +0,0 @@ -#ifndef ADD_PRINT_PEGS_COMMAND_H -#define ADD_PRINT_PEGS_COMMAND_H - -#include -#include - -#include - -#include "SubMeshOps.h" - -#include -#include - -namespace Ogre { class Entity; class SceneNode; } - -/** - * Undoable PartOps print-prep (#859/#863): adds cylindrical alignment pegs to an - * already-SPLIT entity so its parts snap together for 3D printing. Each part - * that shares a stable boundary with another gains a male-peg / female-socket - * connector merged into its geometry. - * - * Adding pegs merges NEW triangles into existing submeshes (the part count is - * unchanged), so — like SplitMeshCommand — this swaps the whole mesh on the - * scene node rather than mutating buffers in place (the safe path for a geometry - * change). redo() runs `PartOpsMesh::addPrintPegsToEntity` once (cached), then - * swaps the pegged mesh onto the node; undo() restores the resident pre-peg mesh. - * Node and entity share a name, so the command targets by that name and survives - * scene rebuilds. Runs in Object mode. - */ -class AddPrintPegsCommand : public QUndoCommand -{ -public: - AddPrintPegsCommand(std::string entityName, - SubMeshOps::PegOptions opts, - QUndoCommand* parent = nullptr); - - void undo() override; - void redo() override; - - bool ok() const { return mOk; } - const QString& error() const { return mError; } - int peggedBoundaries() const { return mPeggedBoundaries; } - int totalPegs() const { return mTotalPegs; } - const std::vector& warnings() const { return mWarnings; } - -private: - Ogre::Entity* resolveEntity() const; - Ogre::Entity* swapEntityMesh(const Ogre::MeshPtr& mesh); - - std::string mEntityName; - SubMeshOps::PegOptions mOpts; - - Ogre::SceneNode* mReselectNode = nullptr; - Ogre::MeshPtr mOriginalMesh; ///< pre-peg mesh, resident for undo. - Ogre::MeshPtr mPeggedMesh; ///< built once on first redo. - bool mBuilt = false; - bool mOk = false; - QString mError; - int mPeggedBoundaries = 0; - int mTotalPegs = 0; - std::vector mWarnings; -}; - -#endif // ADD_PRINT_PEGS_COMMAND_H diff --git a/src/commands/AddPrintPegsCommand_test.cpp b/src/commands/AddPrintPegsCommand_test.cpp deleted file mode 100644 index 8cb8b340..00000000 --- a/src/commands/AddPrintPegsCommand_test.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include - -#include - -#include "commands/AddPrintPegsCommand.h" -#include "SubMeshOps.h" - -// No-Ogre / error-branch coverage for AddPrintPegsCommand (mirrors -// SplitMeshCommand_test.cpp): ctor/text contract, accessor state before redo(), -// redo() against an unresolvable entity (→ ok()==false with an error), and -// undo() before any successful redo (strict no-op). The full split→peg→export -// round-trip is covered by the CLI print-pegs path (verified on Hip Hop -// Dancing.obj: 5 boundaries pegged) and the pure-data SubMeshOps peg tests. - -namespace { -const std::string kBogusEntity = "__qtmesh_nonexistent_entity_for_pegs_test__"; -} - -TEST(AddPrintPegsCommandTest, CtorSetsText) -{ - AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); - EXPECT_EQ(cmd.text(), QStringLiteral("Add Print Alignment Pegs")); -} - -TEST(AddPrintPegsCommandTest, InitialAccessorState) -{ - AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); - EXPECT_FALSE(cmd.ok()); - EXPECT_EQ(cmd.peggedBoundaries(), 0); - EXPECT_EQ(cmd.totalPegs(), 0); - EXPECT_TRUE(cmd.warnings().empty()); -} - -TEST(AddPrintPegsCommandTest, RedoOnUnresolvableEntityFailsCleanly) -{ - AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); - cmd.redo(); // no scene / no entity → error branch - EXPECT_FALSE(cmd.ok()); - EXPECT_FALSE(cmd.error().isEmpty()); - EXPECT_EQ(cmd.totalPegs(), 0); -} - -TEST(AddPrintPegsCommandTest, UndoBeforeRedoIsNoOp) -{ - AddPrintPegsCommand cmd(kBogusEntity, SubMeshOps::PegOptions{}); - EXPECT_NO_THROW(cmd.undo()); - EXPECT_FALSE(cmd.ok()); -} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5761f2ae..298f88b4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -192,7 +192,6 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ComputeSkinWeightsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AutoRigCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/SplitMeshCommand.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/AddPrintPegsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/ExplodePartsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/JoinPartsCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/SkeletonBoneCommands.cpp @@ -562,7 +561,6 @@ ${CMAKE_CURRENT_SOURCE_DIR}/../src/PS1/runtime/MeshReconstructorTexKeys.cpp Qt::QuickWidgets Qt::QuickControls2 meshoptimizer - manifold xatlas qtmesh_sodium qtmesh_updater From 94e915d27fad16bb152983e74b78651995bc6705 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 30 Jul 2026 09:52:35 -0400 Subject: [PATCH 10/12] =?UTF-8?q?feat(#863):=20add=20Solidify=20=E2=80=94?= =?UTF-8?q?=20give=20thin-shell=20split=20parts=20real=20wall=20volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the split/explode work: hollow-shell game assets (single-sided display surfaces with no thickness) expose their hollow interior at a cut when a part is exploded — you see the inner backface through the opening. Split-capping closes the cut RING but can't turn a zero-thickness shell into a solid. `SubMeshOps::solidify` is a pure-data "Solidify"/shell modifier: offset an INNER copy of the surface inward by a thickness (auto ≈1.5% of the AABB diagonal) along area-weighted vertex normals, reverse its winding, and stitch 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 thin part into a closed slab. Opt-in on every surface: - GUI "Solidify thin shells" checkbox in the Split into Parts section → SplitMeshCommand solidify param → SplitOptions::solidifyParts. - CLI `qtmesh segment --split-parts --solidify -o out.glb`. - MCP `split_mesh_by_segments {solidify: true}`. Applied per part BEFORE capParts so the cap closes the now-thicker rim. Tests: 2 new (flat quad → watertight slab of exact thickness; auto-thickness + no-op on empty). Full PartOps suite 34 green. Verified on Hip Hop Dancing.obj via CLI + MCP screenshots: each part ~2× verts, 0 welded open edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- qml/PropertiesPanel.qml | 13 +++- src/CLIPipeline.cpp | 5 +- src/MCPServer.cpp | 4 +- src/PartOpsController.cpp | 4 +- src/PartOpsController.h | 3 +- src/SubMeshOps.cpp | 105 ++++++++++++++++++++++++++++++ src/SubMeshOps.h | 27 ++++++++ src/SubMeshOps_test.cpp | 39 +++++++++++ src/commands/SplitMeshCommand.cpp | 7 +- src/commands/SplitMeshCommand.h | 5 +- 11 files changed, 205 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e58f8d4c..5f103bfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `capOpenBoundaries` (#863 — closes a part's OPEN cut face with a watertight triangle fan so a split part is a solid). *(The #863 3D-print alignment-peg sub-feature — `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dependency, and the `--print-pegs`/`prepare_print_split`/"Prepare for 3D Print" surfaces — was REMOVED: real dowel/socket pegs on organic AI-segmented character joints proved unreliable (a flat cut plane through a hip seam also slices the belly), which is exactly why Meshy/Tripo cut organically but ship no discrete pegs either. Watertight split + explode/join 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) — watertight parts via `capOpenBoundaries`**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow, so an exploded part shows a see-through hole where it was cut from its neighbour). It finds boundary edges (a directed edge a→b whose reverse b→a is absent), consumes them from a per-vertex successor LIST (a rim vertex can have >1 outgoing boundary edge — figure-eight / two loops sharing a vertex — so a single-successor walk left some loops uncapped), walks EVERY loop, and fills each with a CENTROID FAN. Winding is exact: each cap triangle reverses its boundary edge (`centre, b, a`), guaranteeing watertightness for any loop shape / both ends of a tube (a global centroid-normal heuristic flipped the wrong end). The cap centre vertex gets the cap's averaged geometric normal so it shades correctly under `recomputeNormals=false`. Wired via `SplitOptions::capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests; the USER-FACING split — `SplitMeshCommand` GUI+MCP and CLI `segment --split-parts` — sets it TRUE so every part is a watertight solid) AND the explode "Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries`. *(The 3D-print alignment-PEG sub-feature was built and then REMOVED — see the parenthetical at the top of this entry. Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), which is why Meshy/Tripo cut organically but ship no discrete pegs. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dep, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone; `capOpenBoundaries` stayed because split/explode use it.)* 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 `capOpenBoundaries` (#863 — closes a part's OPEN cut face with a watertight triangle fan so a split part is a solid). *(The #863 3D-print alignment-peg sub-feature — `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dependency, and the `--print-pegs`/`prepare_print_split`/"Prepare for 3D Print" surfaces — was REMOVED: real dowel/socket pegs on organic AI-segmented character joints proved unreliable (a flat cut plane through a hip seam also slices the belly), which is exactly why Meshy/Tripo cut organically but ship no discrete pegs either. Watertight split + explode/join 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) — watertight parts via `capOpenBoundaries`**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow, so an exploded part shows a see-through hole where it was cut from its neighbour). It finds boundary edges (a directed edge a→b whose reverse b→a is absent), consumes them from a per-vertex successor LIST (a rim vertex can have >1 outgoing boundary edge — figure-eight / two loops sharing a vertex — so a single-successor walk left some loops uncapped), walks EVERY loop, and fills each with a CENTROID FAN. Winding is exact: each cap triangle reverses its boundary edge (`centre, b, a`), guaranteeing watertightness for any loop shape / both ends of a tube (a global centroid-normal heuristic flipped the wrong end). The cap centre vertex gets the cap's averaged geometric normal so it shades correctly under `recomputeNormals=false`. Wired via `SplitOptions::capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests; the USER-FACING split — `SplitMeshCommand` GUI+MCP and CLI `segment --split-parts` — sets it TRUE so every part is a watertight solid) AND the explode "Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries`. **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. Opt-in: GUI "Solidify thin shells" checkbox in the Split section, 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. *(The 3D-print alignment-PEG sub-feature was built and then REMOVED — see the parenthetical at the top of this entry. Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), which is why Meshy/Tripo cut organically but ship no discrete pegs. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dep, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone; `capOpenBoundaries` stayed because split/explode use it.)* Remaining epic slices: E remaining MCP tools (explode/join), F docs. - **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/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 759149ee..9c70dec1 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -6435,6 +6435,16 @@ Rectangle { checked: true } + // Thin-shell game assets are single-sided surfaces with no wall + // thickness, so an exploded part exposes its hollow interior at the + // cut. "Solidify" gives each part real wall volume first. Default OFF + // (adds geometry; only meaningful for thin shells). + InspectorCheckBox { + id: partOpsSolidifyCheck + text: "Solidify thin shells" + checked: false + } + // Inspector-styled button (same Rectangle+MouseArea idiom as the // in-file InspectorButton, inlined because that component is scoped // to another section's tree, not this top-level Component). @@ -6472,7 +6482,8 @@ Rectangle { PartOpsController.splitSelectedIntoParts( "y", partOpsSplitContent.partOpsCategories[partOpsCategoryCombo.currentIndex], - !partOpsAiCheck.checked) + !partOpsAiCheck.checked, + partOpsSolidifyCheck.checked) } } } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index c7943cfd..33662078 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10357,6 +10357,7 @@ 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 solidify = false; // #863 follow-up: give thin-shell parts wall volume bool jsonOutput = false; bool noModel = false; bool noIslandCleanup = false; // #863: raw labels, skip the split-cleanup pass @@ -10370,6 +10371,7 @@ 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 == "--solidify") { solidify = true; continue; } if (arg == "--write-labels") { if (i + 1 >= argc) { err() << "Error: --write-labels requires an output path." << Qt::endl; @@ -10431,7 +10433,7 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) "[--category auto|body|vegetation|vehicle|building] " "[--no-island-cleanup] " "[--dump-training-data ] [--write-labels ] " - "[--split-parts -o ]" << Qt::endl; + "[--split-parts [--solidify] -o ]" << Qt::endl; return 2; } QFileInfo fi(inputPath); @@ -10638,6 +10640,7 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); SubMeshOps::SplitOptions sopts; // default "Body" prefix, preserve material sopts.capParts = true; // watertight parts (close the cut face) + sopts.solidifyParts = solidify; // --solidify: wall volume for thin shells PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString()); if (!so.ok) { diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 34598a15..f8969086 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -4864,6 +4864,7 @@ QJsonObject MCPServer::toolSplitMeshBySegments(const QJsonObject &args) const QString category = args.value("category").toString().isEmpty() ? QStringLiteral("auto") : args.value("category").toString(); const bool noModel = args.value("no_model").toBool(false); + const bool solidify = args.value("solidify").toBool(false); SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.split_segments"), QStringLiteral("MCP split_mesh_by_segments")); @@ -4873,7 +4874,7 @@ QJsonObject MCPServer::toolSplitMeshBySegments(const QJsonObject &args) // after would dereference the freed pointer (CodeRabbit Critical). const QString entityNameOut = QString::fromStdString(entity->getName()); auto* cmd = new SplitMeshCommand(entity->getName(), axis, category, noModel, - QStringLiteral("Body")); + QStringLiteral("Body"), solidify); UndoManager::getSingleton()->push(cmd); // runs redo() synchronously if (!cmd->ok()) return makeErrorResult(cmd->error().isEmpty() @@ -9177,6 +9178,7 @@ QJsonArray MCPServer::buildToolsList() props["no_model"] = QJsonObject{{"type", "boolean"}, {"description", "Force the offline geometric/rig-prior segmentation (skip the ONNX model). Default false."}}; props["up_axis"] = QJsonObject{{"type", "string"}, {"enum", QJsonArray{"x", "y", "z"}}, {"description", "Mesh up axis for segmentation. Default 'y'."}}; props["category"] = QJsonObject{{"type", "string"}, {"enum", QJsonArray{"auto", "body", "vegetation", "vehicle", "building"}}, {"description", "Segmentation category (default 'auto')."}}; + props["solidify"] = QJsonObject{{"type", "boolean"}, {"description", "Give each part real WALL VOLUME before capping (default false). For thin-shell game assets (single-sided surfaces) an exploded part otherwise exposes its hollow interior at the cut; solidify offsets an inner shell so the cut shows a solid wall. Adds geometry — only meaningful for thin shells."}}; appendTool( "split_mesh_by_segments", "PartOps split (#859/#861): segment the selected/named mesh and REPLACE " diff --git a/src/PartOpsController.cpp b/src/PartOpsController.cpp index c59b6355..6ad11ea1 100644 --- a/src/PartOpsController.cpp +++ b/src/PartOpsController.cpp @@ -74,7 +74,7 @@ bool PartOpsController::canJoin() const } void PartOpsController::splitSelectedIntoParts(const QString& upAxis, const QString& category, - bool noModel) + bool noModel, bool solidify) // NOLINT { const auto* sel = SelectionSet::getSingleton(); if (!sel) { @@ -99,7 +99,7 @@ void PartOpsController::splitSelectedIntoParts(const QString& upAxis, const QStr // push() runs redo() synchronously (AutoRigController pattern); read the // result back. A failed split leaves a harmless no-op on the undo stack. auto* cmd = new SplitMeshCommand(entName, axis, category, noModel, - QStringLiteral("Body")); + QStringLiteral("Body"), solidify); UndoManager::getSingleton()->push(cmd); if (!cmd->ok()) { diff --git a/src/PartOpsController.h b/src/PartOpsController.h index 16e76903..3b645816 100644 --- a/src/PartOpsController.h +++ b/src/PartOpsController.h @@ -54,7 +54,8 @@ class PartOpsController : public QObject * selected mesh. */ Q_INVOKABLE void splitSelectedIntoParts(const QString& upAxis = QStringLiteral("y"), const QString& category = QStringLiteral("auto"), - bool noModel = false); + bool noModel = false, + bool solidify = false); /** Explode the selected multi-submesh mesh into one scene node per part * (undoable). Each part is pushed outward by `distance` × the assembly diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index 4acb12b0..ce7d21b0 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -276,6 +276,13 @@ SubMeshOps::splitByFaceGroups(const std::vector& subMeshes, return result; } + // Give each part real WALL VOLUME first (thin-shell assets) so a cut shows a + // solid wall cross-section instead of the hollow interior. Done BEFORE + // capping so the cap closes the (now thicker) rim. + if (opts.solidifyParts) + for (auto& part : result.subMeshes) + solidify(part, opts.solidifyThickness); + // Close each part's OPEN cut face so every part is a watertight solid — a // split just separates geometry and leaves the seam hollow, so an exploded // part would show a see-through hole where it was cut from its neighbour. @@ -528,3 +535,101 @@ int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) sub.faces.clear(); return caps; } + +int SubMeshOps::solidify(EditableSubMesh& sub, float thickness) +{ + const unsigned int outerN = static_cast(sub.vertices.size()); + if (outerN == 0 || sub.triangles.empty()) + return 0; + + // 1) Area-weighted vertex normals (use existing when the whole mesh has + // them; otherwise compute so the inward offset direction is sane). + std::vector vn(outerN, Ogre::Vector3::ZERO); + bool haveAll = true; + for (unsigned int i = 0; i < outerN; ++i) { + if (sub.vertices[i].hasNormal && sub.vertices[i].normal.squaredLength() > 1e-12f) + vn[i] = sub.vertices[i].normal.normalisedCopy(); + else + haveAll = false; + } + if (!haveAll) { + std::fill(vn.begin(), vn.end(), Ogre::Vector3::ZERO); + for (const EditableTriangle& t : sub.triangles) { + const Ogre::Vector3& p0 = sub.vertices[t.indices[0]].position; + const Ogre::Vector3& p1 = sub.vertices[t.indices[1]].position; + const Ogre::Vector3& p2 = sub.vertices[t.indices[2]].position; + const Ogre::Vector3 fn = (p1 - p0).crossProduct(p2 - p0); // area-weighted (unnormalised) + for (int k = 0; k < 3; ++k) vn[t.indices[k]] += fn; + } + for (auto& n : vn) { if (n.squaredLength() > 1e-12f) n.normalise(); } + } + + // 2) Auto thickness = ~1.5% of the AABB diagonal when not specified. + if (!(thickness > 0.0f)) { + Ogre::Vector3 mn(1e30f, 1e30f, 1e30f), mx(-1e30f, -1e30f, -1e30f); + for (const auto& v : sub.vertices) { mn.makeFloor(v.position); mx.makeCeil(v.position); } + const float diag = (mx - mn).length(); + thickness = (diag > 1e-6f) ? diag * 0.015f : 0.01f; + } + + // 3) Inner shell: duplicate every vertex pushed inward by `thickness` along + // -normal. Attributes carry over; normal flips inward. + sub.vertices.reserve(outerN * 2); + for (unsigned int i = 0; i < outerN; ++i) { + EditableVertex inner = sub.vertices[i]; + inner.position = sub.vertices[i].position - vn[i] * thickness; + inner.normal = -vn[i]; + inner.hasNormal = true; + sub.vertices.push_back(inner); + } + const unsigned int innerBase = outerN; // inner index = outer index + innerBase + + // 4) Inner-shell triangles with REVERSED winding (faces inward, so the slab + // reads solid from inside the wall). + const size_t outerTriCount = sub.triangles.size(); + for (size_t i = 0; i < outerTriCount; ++i) { + const EditableTriangle& t = sub.triangles[i]; + EditableTriangle it; + it.indices[0] = t.indices[0] + innerBase; + it.indices[1] = t.indices[2] + innerBase; // swap 1<->2 to reverse winding + it.indices[2] = t.indices[1] + innerBase; + sub.triangles.push_back(it); + } + + // 5) Stitch a wall between every OPEN boundary edge (outer a→b, interior on + // its LEFT) and its inner counterpart, closing the slab along the rim. + // Wall quad (outer a, outer b, inner b, inner a) → two triangles wound so + // the wall faces OUTWARD (consistent with the outer surface). + auto k64 = [](unsigned int a, unsigned int b) -> uint64_t { + return (static_cast(a) << 32) | b; + }; + std::unordered_map dir; + for (size_t i = 0; i < outerTriCount; ++i) { + const EditableTriangle& t = sub.triangles[i]; + dir[k64(t.indices[0], t.indices[1])]++; + dir[k64(t.indices[1], t.indices[2])]++; + dir[k64(t.indices[2], t.indices[0])]++; + } + int walls = 0; + for (const auto& kv : dir) { + const unsigned int a = static_cast(kv.first >> 32); + const unsigned int b = static_cast(kv.first & 0xffffffff); + if (dir.find(k64(b, a)) != dir.end()) + continue; // interior edge, shared by two tris — not a boundary + const unsigned int ai = a + innerBase, bi = b + innerBase; + // The wall must CANCEL the dangling edges so the slab is watertight: the + // outer surface has boundary edge a→b (needs b→a), and the reverse-wound + // inner shell has boundary edge ai→bi (needs bi→ai). The quad loop + // b→a→ai→bi→b supplies both. Triangulate (b,a,ai) + (b,ai,bi). + EditableTriangle t1, t2; + t1.indices[0] = b; t1.indices[1] = a; t1.indices[2] = ai; + t2.indices[0] = b; t2.indices[1] = ai; t2.indices[2] = bi; + sub.triangles.push_back(t1); + sub.triangles.push_back(t2); + ++walls; + } + + // Triangle list is now canonical; drop any stale n-gon binding. + sub.faces.clear(); + return walls; +} diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h index 5f5e538c..1b03c52c 100644 --- a/src/SubMeshOps.h +++ b/src/SubMeshOps.h @@ -81,6 +81,13 @@ class SubMeshOps * themselves); the user-facing split (SplitMeshCommand) and explode/ * print-prep turn it ON. */ bool capParts = false; + /** Give each part real WALL VOLUME (`solidify`) before capping — for + * thin-shell game assets (single-sided surfaces) an exploded part + * otherwise exposes its hollow interior at the cut. Default OFF (adds + * geometry + only meaningful for thin shells). `solidifyThickness` is in + * model units; <= 0 = auto (~1.5% of the part AABB diagonal). */ + bool solidifyParts = false; + float solidifyThickness = 0.0f; }; struct SplitResult { @@ -179,6 +186,26 @@ class SubMeshOps * space. Edits `sub` in place; returns the number of caps (loops) filled. * Deterministic; pure-data. Skips loops shorter than 3 edges. */ static int capOpenBoundaries(EditableSubMesh& sub); + + // Solidify / shell-thickening (#863 follow-up) ---------------------------- + + /** Give a THIN SHELL real wall volume ("Solidify" modifier). Game character + * assets are usually single-sided display shells with no thickness, so when + * a part is split and exploded the cut exposes the hollow interior (you see + * the inner backface through the opening). This offsets an INNER copy of the + * surface inward by `thickness` along the (area-weighted) vertex normals, + * reverses its winding, and stitches a wall between every OPEN boundary edge + * and its inner counterpart — turning the shell into a closed slab of the + * given thickness. A mesh with no open boundaries (already closed) just + * gains an inner shell (a hollow-walled solid — ideal for printing). + * + * `thickness` is in model units; pass <= 0 to auto-pick ~1.5% of the mesh + * AABB diagonal. Existing vertex normals are used when present, else + * computed. Attributes (uv/colour/tangent/bone-assignments) are copied onto + * the inner + wall verts from their outer source. Edits `sub` in place; + * returns the number of wall quads stitched (0 = mesh was already closed). + * Deterministic; pure-data. */ + static int solidify(EditableSubMesh& sub, float thickness = 0.0f); }; #endif // SUBMESHOPS_H diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index ab6a0efc..33000919 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -451,3 +451,42 @@ TEST(SubMeshOpsTest, CapOpenBoundariesClosesBothEndsOfATube) EXPECT_EQ(caps, 2) << "both tube ends must be capped"; EXPECT_EQ(boundaryEdgeCount(s), 0u) << "tube must be watertight after capping"; } + +// ---- solidify (#863 follow-up: give a thin shell real wall volume) --------- + +TEST(SubMeshOpsTest, SolidifyClosesAnOpenFlatQuadIntoASlab) +{ + // A single flat quad (2 tris, open on all 4 edges) — a zero-thickness shell. + // Solidify must add an inner shell + a wall around the rim so the result is + // a closed watertight slab (0 welded open edges), doubling the verts and + // adding inner + wall triangles. + EditableSubMesh s; s.materialName = "Shell"; + auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); v.normal=Ogre::Vector3(0,1,0); v.hasNormal=true; return v; }; + s.vertices = { V(0,0,0), V(1,0,0), V(1,0,1), V(0,0,1) }; + addTri(s,0,1,2); addTri(s,0,2,3); + ASSERT_GT(boundaryEdgeCount(s), 0u); // open shell + const size_t v0=s.vertices.size(), t0=s.triangles.size(); + const int walls = SubMeshOps::solidify(s, 0.1f); + EXPECT_EQ(walls, 4) << "a quad rim has 4 boundary edges → 4 wall quads"; + EXPECT_EQ(s.vertices.size(), v0*2) << "inner shell duplicates every vertex"; + // outer tris + inner tris (=outer) + 2 tris per wall quad + EXPECT_EQ(s.triangles.size(), t0*2 + 4u*2u); + EXPECT_EQ(boundaryEdgeCount(s), 0u) << "solidified slab must be watertight"; + // The inner shell sits one thickness below the outer along -normal (y). + float miny=1e9f, maxy=-1e9f; + for (const auto& v : s.vertices){ miny=std::min(miny,v.position.y); maxy=std::max(maxy,v.position.y); } + EXPECT_NEAR(maxy-miny, 0.1f, 1e-4f) << "slab thickness == requested"; +} + +TEST(SubMeshOpsTest, SolidifyAutoThicknessAndNoOpOnEmpty) +{ + EditableSubMesh empty; + EXPECT_EQ(SubMeshOps::solidify(empty), 0); // nothing to do + // Auto thickness (<=0) picks a positive value from the AABB. + EditableSubMesh s; s.materialName="S"; + auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); v.normal=Ogre::Vector3(0,1,0); v.hasNormal=true; return v; }; + s.vertices = { V(0,0,0), V(2,0,0), V(2,0,2), V(0,0,2) }; + addTri(s,0,1,2); addTri(s,0,2,3); + EXPECT_EQ(SubMeshOps::solidify(s, /*auto=*/0.0f), 4); + EXPECT_EQ(boundaryEdgeCount(s), 0u); // watertight +} diff --git a/src/commands/SplitMeshCommand.cpp b/src/commands/SplitMeshCommand.cpp index 5afa2744..e69d2f9f 100644 --- a/src/commands/SplitMeshCommand.cpp +++ b/src/commands/SplitMeshCommand.cpp @@ -15,13 +15,15 @@ #include SplitMeshCommand::SplitMeshCommand(std::string entityName, int upAxis, QString category, - bool noModel, QString namePrefix, QUndoCommand* parent) + bool noModel, QString namePrefix, bool solidify, + QUndoCommand* parent) : QUndoCommand(parent) , mEntityName(std::move(entityName)) , mUpAxis(upAxis) , mCategory(std::move(category)) , mNoModel(noModel) , mNamePrefix(std::move(namePrefix)) + , mSolidify(solidify) { setText(QStringLiteral("Split Mesh into Parts")); } @@ -140,6 +142,9 @@ void SplitMeshCommand::redo() // solids — an exploded part otherwise shows a see-through hole where it // was cut from its neighbour (#863). sopts.capParts = true; + // Optionally give thin-shell parts real wall volume so a cut exposes a + // solid wall instead of the hollow interior (#863 follow-up). + sopts.solidifyParts = mSolidify; auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, diff --git a/src/commands/SplitMeshCommand.h b/src/commands/SplitMeshCommand.h index 22f67d55..a141b607 100644 --- a/src/commands/SplitMeshCommand.h +++ b/src/commands/SplitMeshCommand.h @@ -39,12 +39,14 @@ class SplitMeshCommand : public QUndoCommand * @param upAxis 0=X,1=Y,2=Z — forwarded to segmentation. * @param category MeshSegmenter category id ("auto"/"body"/…). * @param noModel force the offline geometric/rig-prior segmentation. - * @param namePrefix submesh name prefix ("Body" → "Body.head" material). */ + * @param namePrefix submesh name prefix ("Body" → "Body.head" material). + * @param solidify give each part real wall volume (thin-shell assets). */ SplitMeshCommand(std::string entityName, int upAxis, QString category, bool noModel, QString namePrefix, + bool solidify = false, QUndoCommand* parent = nullptr); void undo() override; @@ -65,6 +67,7 @@ class SplitMeshCommand : public QUndoCommand QString mCategory; bool mNoModel = false; QString mNamePrefix; + bool mSolidify = false; Ogre::SceneNode* mReselectNode = nullptr; ///< transient: node to reselect after a swap. Ogre::MeshPtr mOriginalMesh; ///< kept resident so undo can restore it. From b7b3fec0a79bd188e6cc5c6c3748bd3e37ef4799 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 30 Jul 2026 23:01:55 -0400 Subject: [PATCH 11/12] feat(#863): recessed-rim cap so thin-shell cut faces read as solid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capOpenBoundaries filled each cut face with a FLAT centroid fan flush with the rim. That is watertight, but on a thin single-sided shell (game character assets) the flat cap has the shell's own back-wall right behind it, so the cut still reads as a see-through hole (the user's "cap doesn't close the junctions"). Now each cut is filled with a RECESSED cap that has a shallow inward RIM: an inner ring (rim verts pushed inward ~20% of the loop radius + contracted 15% toward the centroid), a wall band between rim and inner ring (the visible solid lip), and a centroid fan on the sunk inner ring. The cut now reads as a solid edge without solidifying the whole part. Watertightness is preserved exactly (wall reverses the rim edge b→a; inner ring fully fanned); degenerate loops with no normal fall back to the flat fan. Verified on Hip Hop Dancing.obj (cap-only, no solidify): the head's neck cut now renders as a solid orange surface instead of a dark hollow cavity. Tests: the open-box cap test now asserts watertight + recessed-inward (not the old exact flat-fan counts); tube two-loop watertight test unchanged. Full PartOps suite 34 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- src/SubMeshOps.cpp | 97 ++++++++++++++++++++++++++++++----------- src/SubMeshOps_test.cpp | 52 ++++++++++++---------- 3 files changed, 101 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f103bfc..91d3ebf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `capOpenBoundaries` (#863 — closes a part's OPEN cut face with a watertight triangle fan so a split part is a solid). *(The #863 3D-print alignment-peg sub-feature — `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dependency, and the `--print-pegs`/`prepare_print_split`/"Prepare for 3D Print" surfaces — was REMOVED: real dowel/socket pegs on organic AI-segmented character joints proved unreliable (a flat cut plane through a hip seam also slices the belly), which is exactly why Meshy/Tripo cut organically but ship no discrete pegs either. Watertight split + explode/join 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) — watertight parts via `capOpenBoundaries`**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow, so an exploded part shows a see-through hole where it was cut from its neighbour). It finds boundary edges (a directed edge a→b whose reverse b→a is absent), consumes them from a per-vertex successor LIST (a rim vertex can have >1 outgoing boundary edge — figure-eight / two loops sharing a vertex — so a single-successor walk left some loops uncapped), walks EVERY loop, and fills each with a CENTROID FAN. Winding is exact: each cap triangle reverses its boundary edge (`centre, b, a`), guaranteeing watertightness for any loop shape / both ends of a tube (a global centroid-normal heuristic flipped the wrong end). The cap centre vertex gets the cap's averaged geometric normal so it shades correctly under `recomputeNormals=false`. Wired via `SplitOptions::capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests; the USER-FACING split — `SplitMeshCommand` GUI+MCP and CLI `segment --split-parts` — sets it TRUE so every part is a watertight solid) AND the explode "Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries`. **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. Opt-in: GUI "Solidify thin shells" checkbox in the Split section, 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. *(The 3D-print alignment-PEG sub-feature was built and then REMOVED — see the parenthetical at the top of this entry. Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), which is why Meshy/Tripo cut organically but ship no discrete pegs. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dep, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone; `capOpenBoundaries` stayed because split/explode use it.)* 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 `capOpenBoundaries` (#863 — closes a part's OPEN cut face with a watertight triangle fan so a split part is a solid). *(The #863 3D-print alignment-peg sub-feature — `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dependency, and the `--print-pegs`/`prepare_print_split`/"Prepare for 3D Print" surfaces — was REMOVED: real dowel/socket pegs on organic AI-segmented character joints proved unreliable (a flat cut plane through a hip seam also slices the belly), which is exactly why Meshy/Tripo cut organically but ship no discrete pegs either. Watertight split + explode/join 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) — watertight parts via `capOpenBoundaries`**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow, so an exploded part shows a see-through hole where it was cut from its neighbour). It finds boundary edges (a directed edge a→b whose reverse b→a is absent), consumes them from a per-vertex successor LIST (a rim vertex can have >1 outgoing boundary edge — figure-eight / two loops sharing a vertex — so a single-successor walk left some loops uncapped), walks EVERY loop, and fills each with a **RECESSED cap** (a shallow inward RIM, not a flat fan flush with the cut): an inner ring = each rim vertex pushed inward along the cap normal by ~20% of the loop radius AND contracted 15% toward the centroid, a wall band between rim and inner ring (the visible solid lip), and a centroid fan filling the sunk inner ring. This makes a THIN-shell cut read as a solid edge (a flat fan flush with the rim looks see-through because the shell's back-wall sits right behind it). Watertightness is exact: the wall's outer edge reverses the rim edge (`b→a`) and the inner ring is fully fanned (degenerate loops with no normal fall back to the plain flat fan). The cap verts get the cap's averaged geometric normal so they shade correctly under `recomputeNormals=false`. Wired via `SplitOptions::capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests; the USER-FACING split — `SplitMeshCommand` GUI+MCP and CLI `segment --split-parts` — sets it TRUE so every part is a watertight solid) AND the explode "Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries`. **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. Opt-in: GUI "Solidify thin shells" checkbox in the Split section, 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. *(The 3D-print alignment-PEG sub-feature was built and then REMOVED — see the parenthetical at the top of this entry. Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), which is why Meshy/Tripo cut organically but ship no discrete pegs. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dep, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone; `capOpenBoundaries` stayed because split/explode use it.)* Remaining epic slices: E remaining MCP tools (explode/join), F docs. - **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/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index ce7d21b0..b5a509d0 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -486,45 +486,90 @@ int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) if (edges.size() < 3) continue; - // 3) Centroid-fan fill. New centre vertex copies a rim vertex's - // attributes (material/uv space) with the averaged position. + // 3) Cap the loop. Rather than a single FLAT fan flush with the rim + // (which on a thin shell reads as a see-through hole because there's + // no visible depth), build a RECESSED cap with a shallow inward RIM: + // • an inner ring = each rim vertex pushed INWARD (along the cap + // normal, into the part) by a small depth AND contracted toward + // the loop centroid, so the lip has visible thickness; + // • a wall band between the rim and the inner ring (the solid lip); + // • a centroid fan filling the inner ring (the recessed floor). + // This makes the cut read as a solid edge without solidifying the + // whole part. Watertightness is preserved: the wall's outer edge + // reverses the rim edge (b→a), and the inner ring is fully fanned. Ogre::Vector3 c = Ogre::Vector3::ZERO; for (unsigned int vi : loopVerts) c += sub.vertices[vi].position; c /= static_cast(loopVerts.size()); - EditableVertex centre = sub.vertices[loopVerts[0]]; - centre.position = c; - // Give the centre vertex the cap's averaged geometric normal so it shades - // correctly even when the mesh is built with recomputeNormals=false (the - // split path preserves authored normals) — otherwise the cap centre is - // normal-less and renders black. Cap face (centre,b,a) normal = - // (b-c)×(a-c). + + // Cap normal (average of the flat fan faces) = the inward push direction. Ogre::Vector3 capN = Ogre::Vector3::ZERO; for (const auto& e : edges) { const Ogre::Vector3& pb = sub.vertices[e.second].position; const Ogre::Vector3& pa = sub.vertices[e.first].position; capN += (pb - c).crossProduct(pa - c); } - if (capN.squaredLength() > 1e-12f) { - centre.normal = capN.normalisedCopy(); - centre.hasNormal = true; - } else { - centre.hasNormal = false; - } + const bool haveN = capN.squaredLength() > 1e-12f; + if (haveN) capN.normalise(); + + // Loop radius → rim depth (how far the lip sinks) = 20% of the radius, + // capped so it never exceeds the loop size. + float r2 = 0.0f; + for (unsigned int vi : loopVerts) + r2 = std::max(r2, sub.vertices[vi].position.squaredDistance(c)); + const float radius = std::sqrt(r2); + const float depth = (haveN && radius > 1e-6f) ? radius * 0.20f : 0.0f; + + // The centre vertex (recessed cap floor centre), sunk one depth inward. + EditableVertex centre = sub.vertices[loopVerts[0]]; + centre.position = c - capN * depth; + if (haveN) { centre.normal = capN; centre.hasNormal = true; } + else { centre.hasNormal = false; } const unsigned int cIdx = static_cast(sub.vertices.size()); sub.vertices.push_back(centre); - // Winding — the ONLY watertight choice: a boundary edge a→b has the part - // interior on its LEFT, so the cap triangle must contain the REVERSE edge - // b→a to cancel it. Emit (centre, b, a) for every consumed edge. This is - // exact for any loop shape and both ends of a tube, unlike a global - // centroid-normal heuristic (which flips the wrong end and left the rim - // open — the "gap not closed on all joints" bug). + if (depth <= 0.0f) { + // Degenerate loop (no normal / zero radius) → plain flat fan. + for (const auto& e : edges) { + EditableTriangle t; + t.indices[0] = cIdx; t.indices[1] = e.second; t.indices[2] = e.first; + sub.triangles.push_back(t); + } + ++caps; + continue; + } + + // Inner-ring vertex per rim vertex: sunk inward by `depth` and contracted + // 15% toward the centroid so the lip is visibly beveled. Map rim index → + // its inner-ring index. + std::unordered_map innerOf; + for (unsigned int vi : loopVerts) { + if (innerOf.count(vi)) continue; + EditableVertex iv = sub.vertices[vi]; + const Ogre::Vector3 p = sub.vertices[vi].position; + iv.position = (p + (c - p) * 0.15f) - capN * depth; + iv.normal = capN; iv.hasNormal = true; + innerOf[vi] = static_cast(sub.vertices.size()); + sub.vertices.push_back(iv); + } + + // Wall band (rim edge a→b → its inner ai/bi). To cancel the rim edge a→b + // the wall must contain b→a; loop b→a→ai→bi supplies it → (b,a,ai)+(b,ai,bi). + for (const auto& e : edges) { + const unsigned int a = e.first, b = e.second; + const unsigned int ai = innerOf[a], bi = innerOf[b]; + EditableTriangle w1, w2; + w1.indices[0] = b; w1.indices[1] = a; w1.indices[2] = ai; + w2.indices[0] = b; w2.indices[1] = ai; w2.indices[2] = bi; + sub.triangles.push_back(w1); + sub.triangles.push_back(w2); + } + // Recessed floor: fan the INNER ring to the sunk centre. Inner edge is + // ai→bi (same direction as the rim), so the floor face is (centre, bi, ai). for (const auto& e : edges) { - EditableTriangle t; - t.indices[0] = cIdx; - t.indices[1] = e.second; // b - t.indices[2] = e.first; // a - sub.triangles.push_back(t); + const unsigned int ai = innerOf[e.first], bi = innerOf[e.second]; + EditableTriangle f; + f.indices[0] = cIdx; f.indices[1] = bi; f.indices[2] = ai; + sub.triangles.push_back(f); } ++caps; } diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index 33000919..55429666 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -368,11 +368,28 @@ TEST(SubMeshOpsTest, ExplodeOffsetsPushOutwardFromCenter) // ---- capOpenBoundaries (#863 close split cut face) ------------------------ +// Count directed boundary edges (a→b with no b→a) — 0 means watertight. +static size_t boundaryEdgeCount(const EditableSubMesh& s) +{ + std::map,int> d; + for (const auto& t : s.triangles) { + d[{t.indices[0],t.indices[1]}]++; + d[{t.indices[1],t.indices[2]}]++; + d[{t.indices[2],t.indices[0]}]++; + } + size_t open = 0; + for (const auto& kv : d) + if (!d.count({kv.first.second, kv.first.first})) open += 1; + return open; +} + TEST(SubMeshOpsTest, CapOpenBoundaryClosesHole) { // An open-topped box: 8 cube corners, all 5 side+bottom faces, TOP missing. // The top rim (verts 4,5,6,7 at y=1) is one open boundary loop of 4 edges. - // capOpenBoundaries should fill it → 1 cap, +1 centre vert, +4 triangles. + // capOpenBoundaries fills it with a RECESSED cap (a shallow inward rim so a + // thin-shell cut reads as a solid edge): the rim is sealed watertight, the + // cap sinks INWARD (below the y=1 rim), and geometry is added. EditableSubMesh s; s.materialName = "Box"; // bottom (y=0): 0,1,2,3 top (y=1): 4,5,6,7 @@ -389,15 +406,19 @@ TEST(SubMeshOpsTest, CapOpenBoundaryClosesHole) const size_t triBefore = s.triangles.size(); const size_t vBefore = s.vertices.size(); + ASSERT_GT(boundaryEdgeCount(s), 0u); // open before const int caps = SubMeshOps::capOpenBoundaries(s); EXPECT_EQ(caps, 1); - EXPECT_EQ(s.vertices.size(), vBefore + 1); // one centroid vertex - EXPECT_EQ(s.triangles.size(), triBefore + 4); // one tri per rim edge - // The new centre vertex sits at the rim centroid (0.5,1,0.5). - const auto& cv = s.vertices.back(); - EXPECT_NEAR(cv.position.x, 0.5f, 1e-4f); - EXPECT_NEAR(cv.position.y, 1.0f, 1e-4f); - EXPECT_NEAR(cv.position.z, 0.5f, 1e-4f); + EXPECT_EQ(boundaryEdgeCount(s), 0u) << "cap must seal the rim watertight"; + EXPECT_GT(s.vertices.size(), vBefore); // inner ring + centre added + EXPECT_GT(s.triangles.size(), triBefore); // wall band + floor fan + // The recessed cap sinks INWARD: at least one new vertex sits below y=1 + // (the rim). The interior is toward -Y (centroid at y=0.5), so the inward + // cap normal is -Y and the floor/inner-ring verts are at y < 1. + float minY = 1e9f; + for (size_t i = vBefore; i < s.vertices.size(); ++i) + minY = std::min(minY, s.vertices[i].position.y); + EXPECT_LT(minY, 1.0f) << "cap should be recessed inward, not flush with the rim"; } TEST(SubMeshOpsTest, CapOpenBoundariesNoOpWhenClosed) @@ -412,21 +433,6 @@ TEST(SubMeshOpsTest, CapOpenBoundariesNoOpWhenClosed) EXPECT_EQ(s.triangles.size(), before); } -// Count directed boundary edges (a→b with no b→a) — 0 means watertight. -static size_t boundaryEdgeCount(const EditableSubMesh& s) -{ - std::map,int> d; - for (const auto& t : s.triangles) { - d[{t.indices[0],t.indices[1]}]++; - d[{t.indices[1],t.indices[2]}]++; - d[{t.indices[2],t.indices[0]}]++; - } - size_t open = 0; - for (const auto& kv : d) - if (!d.count({kv.first.second, kv.first.first})) open += 1; - return open; -} - TEST(SubMeshOpsTest, CapOpenBoundariesClosesBothEndsOfATube) { // An open tube (a ring extruded along Y, NO end caps): TWO separate boundary From 495356d907544a64672b870e956fb08c5a439164 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 30 Jul 2026 23:15:53 -0400 Subject: [PATCH 12/12] refactor(#863): remove the cut-face cap feature (kept solidify) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping a split part's cut RING is geometrically watertight, but on a thin single-sided game shell the cut still LOOKS hollow (the shell's own back-wall sits right behind the flat cap). A recessed-rim cap variant was tried to make it read solid but produced mesh artifacts. Solidify (which gives the part real wall volume AND seals it) is the right tool for these assets, so drop cap entirely and revisit later if a genuinely-solid-mesh use case needs it. Removed: - SubMeshOps::capOpenBoundaries + SplitOptions::capParts. - ExplodePartsCommand / PartOpsScene::explodeEntity capBoundaries param. - PartOpsController::explodeSelected capBoundaries param. - The GUI "Cap open boundaries (watertight)" explode checkbox. - CLI/SplitMeshCommand capParts=true wiring, cap unit tests. KEPT: split, explode/join, and Solidify (opt-in "Solidify thin shells" checkbox / CLI --solidify / MCP solidify:true — this both adds wall volume and seals each part watertight). The plain split now just separates geometry (exact tri count, as the CLI coverage test asserts again). 31 PartOps tests green; app builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- qml/PropertiesPanel.qml | 12 +- src/CLIPipeline.cpp | 1 - ...LIPipeline_cmdsplitparts_coverage_test.cpp | 9 +- src/PartOpsController.cpp | 4 +- src/PartOpsController.h | 2 +- src/PartOpsScene.cpp | 9 +- src/PartOpsScene.h | 3 +- src/SubMeshOps.cpp | 188 +----------------- src/SubMeshOps.h | 35 +--- src/SubMeshOps_test.cpp | 77 +------ src/commands/ExplodePartsCommand.cpp | 6 +- src/commands/ExplodePartsCommand.h | 5 +- src/commands/SplitMeshCommand.cpp | 7 +- 14 files changed, 25 insertions(+), 335 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 91d3ebf5..bbcf0f26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,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 `capOpenBoundaries` (#863 — closes a part's OPEN cut face with a watertight triangle fan so a split part is a solid). *(The #863 3D-print alignment-peg sub-feature — `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dependency, and the `--print-pegs`/`prepare_print_split`/"Prepare for 3D Print" surfaces — was REMOVED: real dowel/socket pegs on organic AI-segmented character joints proved unreliable (a flat cut plane through a hip seam also slices the belly), which is exactly why Meshy/Tripo cut organically but ship no discrete pegs either. Watertight split + explode/join 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) — watertight parts via `capOpenBoundaries`**: `SubMeshOps::capOpenBoundaries` closes a split part's OPEN cut face (a split leaves it hollow, so an exploded part shows a see-through hole where it was cut from its neighbour). It finds boundary edges (a directed edge a→b whose reverse b→a is absent), consumes them from a per-vertex successor LIST (a rim vertex can have >1 outgoing boundary edge — figure-eight / two loops sharing a vertex — so a single-successor walk left some loops uncapped), walks EVERY loop, and fills each with a **RECESSED cap** (a shallow inward RIM, not a flat fan flush with the cut): an inner ring = each rim vertex pushed inward along the cap normal by ~20% of the loop radius AND contracted 15% toward the centroid, a wall band between rim and inner ring (the visible solid lip), and a centroid fan filling the sunk inner ring. This makes a THIN-shell cut read as a solid edge (a flat fan flush with the rim looks see-through because the shell's back-wall sits right behind it). Watertightness is exact: the wall's outer edge reverses the rim edge (`b→a`) and the inner ring is fully fanned (degenerate loops with no normal fall back to the plain flat fan). The cap verts get the cap's averaged geometric normal so they shade correctly under `recomputeNormals=false`. Wired via `SplitOptions::capParts` (default OFF so the pure-split algorithm keeps exact counts for unit tests; the USER-FACING split — `SplitMeshCommand` GUI+MCP and CLI `segment --split-parts` — sets it TRUE so every part is a watertight solid) AND the explode "Cap open boundaries" checkbox → `explodeSelected(distance, capBoundaries)` → `ExplodePartsCommand`/`PartOpsScene::explodeEntity` `capBoundaries`. **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. Opt-in: GUI "Solidify thin shells" checkbox in the Split section, 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. *(The 3D-print alignment-PEG sub-feature was built and then REMOVED — see the parenthetical at the top of this entry. Real dowel/socket connectors on organic AI-segmented character joints proved unreliable: there is no safe flat cut plane through a hip/shoulder seam (it also slices the torso body), which is why Meshy/Tripo cut organically but ship no discrete pegs. `preparePrintPegs`/`buildAlignmentPegs`/`estimateBoundaryPlane`, `AddPrintPegsCommand`, the Manifold CSG dep, `--print-pegs`, MCP `prepare_print_split`, and the "Prepare for 3D Print" button are all gone; `capOpenBoundaries` stayed because split/explode use it.)* 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`. 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. - **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/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index 9c70dec1..b163581c 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -6583,15 +6583,6 @@ Rectangle { } } - // Close each part's open cut face so exploded parts are watertight - // solids (#863) — useful before 3D printing. - property bool capBoundaries: false - InspectorCheckBox { - text: "Cap open boundaries (watertight)" - checked: partOpsEjContent.capBoundaries - onCheckedChanged: partOpsEjContent.capBoundaries = checked - } - // --- Explode button --- Rectangle { id: partOpsExplodeBtn @@ -6623,8 +6614,7 @@ Rectangle { onClicked: { partOpsEjFeedback.color = PropertiesPanelController.textColor partOpsEjFeedback.text = "Exploding…" - PartOpsController.explodeSelected(partOpsEjContent.explodeDistance, - partOpsEjContent.capBoundaries) + PartOpsController.explodeSelected(partOpsEjContent.explodeDistance) } } } diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 33662078..206e62ff 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -10639,7 +10639,6 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) if (splitParts) { auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); SubMeshOps::SplitOptions sopts; // default "Body" prefix, preserve material - sopts.capParts = true; // watertight parts (close the cut face) sopts.solidifyParts = solidify; // --solidify: wall volume for thin shells PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString()); diff --git a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp index 07d01316..1931bc7e 100644 --- a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -163,13 +163,10 @@ TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitRiggedHumanoidPreservesTrisAnd EXPECT_GT(e->getMesh()->getNumSubMeshes(), 1u) << "split should produce multiple part submeshes"; - // The original geometry is preserved; the split ALSO caps each part's open - // cut face into a watertight solid (capParts=true on the user-facing path), - // which adds a fan of cap triangles — so the count is >= the source, not - // exactly equal. Boundary vertex duplication itself adds verts, not tris. + // Triangle count preserved: the split only separates geometry (boundary + // vertex duplication adds verts, not tris) — no cap/solidify by default. MeshInfo info = CLIPipeline::extractMeshInfo(e, "parts.fbx"); - EXPECT_GE(static_cast(info.triangles), srcTris) - << "split must preserve the source geometry (plus watertight caps)"; + EXPECT_EQ(static_cast(info.triangles), srcTris); // Skinned fixture retains its skeleton + bone assignments (#861 criterion). EXPECT_TRUE(e->getMesh()->hasSkeleton()) diff --git a/src/PartOpsController.cpp b/src/PartOpsController.cpp index 6ad11ea1..c530b8e4 100644 --- a/src/PartOpsController.cpp +++ b/src/PartOpsController.cpp @@ -111,7 +111,7 @@ void PartOpsController::splitSelectedIntoParts(const QString& upAxis, const QStr emit splitFinished(tr("Split into %1 part submeshes.").arg(cmd->createdSubMeshes()), false); } -void PartOpsController::explodeSelected(double distance, bool capBoundaries) +void PartOpsController::explodeSelected(double distance) { const auto* sel = SelectionSet::getSingleton(); if (!sel) { @@ -130,7 +130,7 @@ void PartOpsController::explodeSelected(double distance, bool capBoundaries) SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("explode_parts")); const std::string entName = entities.first()->getName(); - auto* cmd = new ExplodePartsCommand(entName, static_cast(distance), capBoundaries); + auto* cmd = new ExplodePartsCommand(entName, static_cast(distance)); UndoManager::getSingleton()->push(cmd); if (!cmd->ok()) { diff --git a/src/PartOpsController.h b/src/PartOpsController.h index 3b645816..c97a6298 100644 --- a/src/PartOpsController.h +++ b/src/PartOpsController.h @@ -61,7 +61,7 @@ class PartOpsController : public QObject * (undoable). Each part is pushed outward by `distance` × the assembly * diagonal. Emits explodeFinished(status, isError). No-op (error) without * a single multi-submesh selection. */ - Q_INVOKABLE void explodeSelected(double distance = 0.5, bool capBoundaries = false); + Q_INVOKABLE void explodeSelected(double distance = 0.5); /** Join the selected part entities (2+) back into one fused mesh, baking * their world transforms into vertices (undoable). Emits diff --git a/src/PartOpsScene.cpp b/src/PartOpsScene.cpp index 7038ffcc..0437a0c4 100644 --- a/src/PartOpsScene.cpp +++ b/src/PartOpsScene.cpp @@ -26,7 +26,7 @@ Ogre::Vector3 subMeshCentroid(const EditableSubMesh& sub) PartOpsScene::ExplodeResult PartOpsScene::explodeEntity(Ogre::Entity* entity, float distance, - const std::string& baseName, bool capBoundaries) + const std::string& baseName) { ExplodeResult out; if (!entity || !entity->getMesh()) { @@ -44,13 +44,6 @@ PartOpsScene::explodeEntity(Ogre::Entity* entity, float distance, return out; } - // Optionally close each part's open cut face so an exploded part is a - // watertight solid (#863). Done on the read-out copies before per-part mesh - // build; centroids/bounds below are computed from the (capped) copies. - if (capBoundaries) - for (auto& s : subs) - SubMeshOps::capOpenBoundaries(s); - QString skelName; if (entity->getMesh()->hasSkeleton()) skelName = QString::fromStdString(entity->getMesh()->getSkeletonName()); diff --git a/src/PartOpsScene.h b/src/PartOpsScene.h index 6e5f6a7f..816d3f30 100644 --- a/src/PartOpsScene.h +++ b/src/PartOpsScene.h @@ -64,8 +64,7 @@ class PartOpsScene * geometry, or a single-submesh mesh (nothing to explode). */ static ExplodeResult explodeEntity(Ogre::Entity* entity, float distance, - const std::string& baseName, - bool capBoundaries = false); + const std::string& baseName); // ------------------------------------------------------------------------- // Join: N part entities (with world transforms) -> one fused mesh. diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index b5a509d0..8589e65a 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -276,22 +276,13 @@ SubMeshOps::splitByFaceGroups(const std::vector& subMeshes, return result; } - // Give each part real WALL VOLUME first (thin-shell assets) so a cut shows a - // solid wall cross-section instead of the hollow interior. Done BEFORE - // capping so the cap closes the (now thicker) rim. + // Give each part real WALL VOLUME (thin-shell assets) so a cut shows a solid + // wall cross-section instead of the hollow interior. Solidify also SEALS each + // part watertight (it walls every open boundary). if (opts.solidifyParts) for (auto& part : result.subMeshes) solidify(part, opts.solidifyThickness); - // Close each part's OPEN cut face so every part is a watertight solid — a - // split just separates geometry and leaves the seam hollow, so an exploded - // part would show a see-through hole where it was cut from its neighbour. - // On by default (opts.capParts); print-prep re-caps harmlessly (idempotent - // once closed). - if (opts.capParts) - for (auto& part : result.subMeshes) - capOpenBoundaries(part); - result.duplicatedBoundaryVertices = duplicated; result.createdSubMeshes = static_cast(result.subMeshes.size()); result.ok = true; @@ -408,179 +399,6 @@ SubMeshOps::explodeOffsets(const std::vector& partCentroids, return offsets; } -int SubMeshOps::capOpenBoundaries(EditableSubMesh& sub) -{ - const size_t triCount = sub.triangles.size(); - if (triCount == 0 || sub.vertices.empty()) - return 0; - - // 1) Boundary edges = directed edges whose REVERSE is not also present. In a - // closed manifold every edge appears once in each direction; an open cut - // face leaves its rim edges with no opposite. Key by the ordered vertex - // pair so we can find the unmatched ones, and remember the directed edge - // (a→b) so the cap can be wound consistently with the source triangles. - auto key = [](unsigned int a, unsigned int b) -> uint64_t { - return (static_cast(a) << 32) | b; - }; - std::unordered_map dirCount; // directed edge → count - for (const EditableTriangle& t : sub.triangles) { - dirCount[key(t.indices[0], t.indices[1])]++; - dirCount[key(t.indices[1], t.indices[2])]++; - dirCount[key(t.indices[2], t.indices[0])]++; - } - // A directed edge a→b is a boundary edge when b→a is absent. A rim vertex can - // have MORE than one outgoing boundary edge (a figure-eight / pinched cut, or - // two separate rim loops touching a shared vertex — common at shoulders/hips), - // so keep a LIST of successors per vertex and CONSUME them as we walk. A - // single-successor map silently drops the extra edges and leaves those loops - // uncapped (the "gap not closed on all joints" bug). - std::unordered_map> succ; - size_t boundaryEdges = 0; - for (const auto& kv : dirCount) { - const unsigned int a = static_cast(kv.first >> 32); - const unsigned int b = static_cast(kv.first & 0xffffffff); - if (dirCount.find(key(b, a)) == dirCount.end()) { - succ[a].push_back(b); // boundary edge a→b (interior on its left) - ++boundaryEdges; - } - } - if (boundaryEdges == 0) - return 0; // already closed - - // 2) Walk each boundary loop by consuming edges from `succ`. Every boundary - // edge is used exactly once, so ALL rim loops get capped — not just the - // first one reachable from each vertex. - auto popSucc = [&](unsigned int a, bool& ok) -> unsigned int { - auto it = succ.find(a); - if (it == succ.end() || it->second.empty()) { ok = false; return 0; } - unsigned int b = it->second.back(); - it->second.pop_back(); - if (it->second.empty()) succ.erase(it); - ok = true; - return b; - }; - int caps = 0; - size_t consumed = 0; - while (consumed < boundaryEdges) { - // Find any vertex that still has an unused outgoing boundary edge. - unsigned int start = 0; bool found = false; - for (const auto& kv : succ) { if (!kv.second.empty()) { start = kv.first; found = true; break; } } - if (!found) - break; - // Record the actual DIRECTED boundary edges (a→b) we consume, in order. - std::vector> edges; - std::vector loopVerts; - unsigned int cur = start; - // Follow successors, consuming each edge, until we return to start or hit - // a vertex with no remaining successor (open chain — still fan it). - for (;;) { - bool ok = false; - unsigned int nxt = popSucc(cur, ok); - if (!ok) break; - ++consumed; - edges.emplace_back(cur, nxt); - loopVerts.push_back(cur); - cur = nxt; - if (cur == start) break; // closed loop - } - if (edges.size() < 3) - continue; - - // 3) Cap the loop. Rather than a single FLAT fan flush with the rim - // (which on a thin shell reads as a see-through hole because there's - // no visible depth), build a RECESSED cap with a shallow inward RIM: - // • an inner ring = each rim vertex pushed INWARD (along the cap - // normal, into the part) by a small depth AND contracted toward - // the loop centroid, so the lip has visible thickness; - // • a wall band between the rim and the inner ring (the solid lip); - // • a centroid fan filling the inner ring (the recessed floor). - // This makes the cut read as a solid edge without solidifying the - // whole part. Watertightness is preserved: the wall's outer edge - // reverses the rim edge (b→a), and the inner ring is fully fanned. - Ogre::Vector3 c = Ogre::Vector3::ZERO; - for (unsigned int vi : loopVerts) c += sub.vertices[vi].position; - c /= static_cast(loopVerts.size()); - - // Cap normal (average of the flat fan faces) = the inward push direction. - Ogre::Vector3 capN = Ogre::Vector3::ZERO; - for (const auto& e : edges) { - const Ogre::Vector3& pb = sub.vertices[e.second].position; - const Ogre::Vector3& pa = sub.vertices[e.first].position; - capN += (pb - c).crossProduct(pa - c); - } - const bool haveN = capN.squaredLength() > 1e-12f; - if (haveN) capN.normalise(); - - // Loop radius → rim depth (how far the lip sinks) = 20% of the radius, - // capped so it never exceeds the loop size. - float r2 = 0.0f; - for (unsigned int vi : loopVerts) - r2 = std::max(r2, sub.vertices[vi].position.squaredDistance(c)); - const float radius = std::sqrt(r2); - const float depth = (haveN && radius > 1e-6f) ? radius * 0.20f : 0.0f; - - // The centre vertex (recessed cap floor centre), sunk one depth inward. - EditableVertex centre = sub.vertices[loopVerts[0]]; - centre.position = c - capN * depth; - if (haveN) { centre.normal = capN; centre.hasNormal = true; } - else { centre.hasNormal = false; } - const unsigned int cIdx = static_cast(sub.vertices.size()); - sub.vertices.push_back(centre); - - if (depth <= 0.0f) { - // Degenerate loop (no normal / zero radius) → plain flat fan. - for (const auto& e : edges) { - EditableTriangle t; - t.indices[0] = cIdx; t.indices[1] = e.second; t.indices[2] = e.first; - sub.triangles.push_back(t); - } - ++caps; - continue; - } - - // Inner-ring vertex per rim vertex: sunk inward by `depth` and contracted - // 15% toward the centroid so the lip is visibly beveled. Map rim index → - // its inner-ring index. - std::unordered_map innerOf; - for (unsigned int vi : loopVerts) { - if (innerOf.count(vi)) continue; - EditableVertex iv = sub.vertices[vi]; - const Ogre::Vector3 p = sub.vertices[vi].position; - iv.position = (p + (c - p) * 0.15f) - capN * depth; - iv.normal = capN; iv.hasNormal = true; - innerOf[vi] = static_cast(sub.vertices.size()); - sub.vertices.push_back(iv); - } - - // Wall band (rim edge a→b → its inner ai/bi). To cancel the rim edge a→b - // the wall must contain b→a; loop b→a→ai→bi supplies it → (b,a,ai)+(b,ai,bi). - for (const auto& e : edges) { - const unsigned int a = e.first, b = e.second; - const unsigned int ai = innerOf[a], bi = innerOf[b]; - EditableTriangle w1, w2; - w1.indices[0] = b; w1.indices[1] = a; w1.indices[2] = ai; - w2.indices[0] = b; w2.indices[1] = ai; w2.indices[2] = bi; - sub.triangles.push_back(w1); - sub.triangles.push_back(w2); - } - // Recessed floor: fan the INNER ring to the sunk centre. Inner edge is - // ai→bi (same direction as the rim), so the floor face is (centre, bi, ai). - for (const auto& e : edges) { - const unsigned int ai = innerOf[e.first], bi = innerOf[e.second]; - EditableTriangle f; - f.indices[0] = cIdx; f.indices[1] = bi; f.indices[2] = ai; - sub.triangles.push_back(f); - } - ++caps; - } - - // Cap triangles were appended; drop any stale n-gon `faces` binding so the - // triangle list is authoritative downstream (buildSubMeshBuffers rebuilds). - if (caps > 0) - sub.faces.clear(); - return caps; -} - int SubMeshOps::solidify(EditableSubMesh& sub, float thickness) { const unsigned int outerN = static_cast(sub.vertices.size()); diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h index 1b03c52c..3450b0ca 100644 --- a/src/SubMeshOps.h +++ b/src/SubMeshOps.h @@ -73,19 +73,12 @@ class SubMeshOps * preserving the source material. The Ogre adapter creates the * materials; the core only records the intended name. */ bool assignPartMaterials = false; - /** Close each part's OPEN cut face (the seam left hollow by the split) - * with a triangle fan so every part is a watertight solid — otherwise - * an exploded part shows a see-through hole where it was cut from its - * neighbour. Default OFF so the pure-split algorithm keeps exact vertex/ - * triangle counts (unit tests, downstream callers that re-cap - * themselves); the user-facing split (SplitMeshCommand) and explode/ - * print-prep turn it ON. */ - bool capParts = false; - /** Give each part real WALL VOLUME (`solidify`) before capping — for - * thin-shell game assets (single-sided surfaces) an exploded part - * otherwise exposes its hollow interior at the cut. Default OFF (adds - * geometry + only meaningful for thin shells). `solidifyThickness` is in - * model units; <= 0 = auto (~1.5% of the part AABB diagonal). */ + /** Give each part real WALL VOLUME (`solidify`) — for thin-shell game + * assets (single-sided surfaces) an exploded part otherwise exposes its + * hollow interior at the cut. This also SEALS each part watertight (it + * walls every open boundary). Default OFF (adds geometry + only + * meaningful for thin shells). `solidifyThickness` is in model units; + * <= 0 = auto (~1.5% of the part AABB diagonal). */ bool solidifyParts = false; float solidifyThickness = 0.0f; }; @@ -171,22 +164,6 @@ class SubMeshOps const Ogre::AxisAlignedBox& assemblyBounds, float distance); - // ------------------------------------------------------------------------- - // Boundary capping (#863) — close the open cut face of a split part - // ------------------------------------------------------------------------- - - /** Cap the OPEN boundary of a split part so it becomes a watertight solid - * (a split leaves the cut face as a hole — bad for 3D printing, and a peg - * needs a solid face to attach to). Finds every boundary edge (an edge used - * by exactly ONE triangle), chains them into closed loops, and fills each - * loop with a CENTROID FAN: one new vertex at the loop's centroid + a - * triangle per boundary edge, wound so the cap faces OUTWARD (away from the - * part's centroid). Copies a representative boundary vertex's attributes - * onto the new centroid verts so the cap shares the part's material/uv - * space. Edits `sub` in place; returns the number of caps (loops) filled. - * Deterministic; pure-data. Skips loops shorter than 3 edges. */ - static int capOpenBoundaries(EditableSubMesh& sub); - // Solidify / shell-thickening (#863 follow-up) ---------------------------- /** Give a THIN SHELL real wall volume ("Solidify" modifier). Game character diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp index 55429666..4d02d8a6 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -366,7 +366,7 @@ TEST(SubMeshOpsTest, ExplodeOffsetsPushOutwardFromCenter) EXPECT_NEAR(offs[1].length(), 1.0f, 1e-5f); } -// ---- capOpenBoundaries (#863 close split cut face) ------------------------ +// ---- solidify watertightness helper -------------------------------------- // Count directed boundary edges (a→b with no b→a) — 0 means watertight. static size_t boundaryEdgeCount(const EditableSubMesh& s) @@ -383,81 +383,6 @@ static size_t boundaryEdgeCount(const EditableSubMesh& s) return open; } -TEST(SubMeshOpsTest, CapOpenBoundaryClosesHole) -{ - // An open-topped box: 8 cube corners, all 5 side+bottom faces, TOP missing. - // The top rim (verts 4,5,6,7 at y=1) is one open boundary loop of 4 edges. - // capOpenBoundaries fills it with a RECESSED cap (a shallow inward rim so a - // thin-shell cut reads as a solid edge): the rim is sealed watertight, the - // cap sinks INWARD (below the y=1 rim), and geometry is added. - EditableSubMesh s; - s.materialName = "Box"; - // bottom (y=0): 0,1,2,3 top (y=1): 4,5,6,7 - auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); return v; }; - s.vertices = { V(0,0,0),V(1,0,0),V(1,0,1),V(0,0,1), - V(0,1,0),V(1,1,0),V(1,1,1),V(0,1,1) }; - auto Q = [&](unsigned a,unsigned b,unsigned c,unsigned d){ addTri(s,a,b,c); addTri(s,a,c,d); }; - Q(0,1,2,3); // bottom - Q(0,4,5,1); // front - Q(1,5,6,2); // right - Q(2,6,7,3); // back - Q(3,7,4,0); // left - // NO top → verts 4,5,6,7 form the open rim. - - const size_t triBefore = s.triangles.size(); - const size_t vBefore = s.vertices.size(); - ASSERT_GT(boundaryEdgeCount(s), 0u); // open before - const int caps = SubMeshOps::capOpenBoundaries(s); - EXPECT_EQ(caps, 1); - EXPECT_EQ(boundaryEdgeCount(s), 0u) << "cap must seal the rim watertight"; - EXPECT_GT(s.vertices.size(), vBefore); // inner ring + centre added - EXPECT_GT(s.triangles.size(), triBefore); // wall band + floor fan - // The recessed cap sinks INWARD: at least one new vertex sits below y=1 - // (the rim). The interior is toward -Y (centroid at y=0.5), so the inward - // cap normal is -Y and the floor/inner-ring verts are at y < 1. - float minY = 1e9f; - for (size_t i = vBefore; i < s.vertices.size(); ++i) - minY = std::min(minY, s.vertices[i].position.y); - EXPECT_LT(minY, 1.0f) << "cap should be recessed inward, not flush with the rim"; -} - -TEST(SubMeshOpsTest, CapOpenBoundariesNoOpWhenClosed) -{ - // A closed tetrahedron: every edge is shared by two faces → no boundary. - EditableSubMesh s; s.materialName = "Tet"; - auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); return v; }; - s.vertices = { V(0,0,0), V(1,0,0), V(0,1,0), V(0,0,1) }; - addTri(s,0,2,1); addTri(s,0,1,3); addTri(s,0,3,2); addTri(s,1,2,3); - const size_t before = s.triangles.size(); - EXPECT_EQ(SubMeshOps::capOpenBoundaries(s), 0); - EXPECT_EQ(s.triangles.size(), before); -} - -TEST(SubMeshOpsTest, CapOpenBoundariesClosesBothEndsOfATube) -{ - // An open tube (a ring extruded along Y, NO end caps): TWO separate boundary - // loops. The old single-successor walk capped only one; the multi-successor - // walk must close BOTH → 0 boundary edges after, watertight. - EditableSubMesh s; s.materialName = "Tube"; - const int seg = 8; - auto V = [](float x,float y,float z){ EditableVertex v; v.position=Ogre::Vector3(x,y,z); return v; }; - for (int i = 0; i < seg; ++i) { - const float a = 2.0f*float(M_PI)*float(i)/float(seg); - s.vertices.push_back(V(std::cos(a), 0.f, std::sin(a))); // bottom ring - s.vertices.push_back(V(std::cos(a), 2.f, std::sin(a))); // top ring - } - for (int i = 0; i < seg; ++i) { - const int j = (i+1)%seg; - const unsigned b0=2*i, t0=2*i+1, b1=2*j, t1=2*j+1; - addTri(s, b0, b1, t1); - addTri(s, b0, t1, t0); - } - ASSERT_GT(boundaryEdgeCount(s), 0u); // open at both ends - const int caps = SubMeshOps::capOpenBoundaries(s); - EXPECT_EQ(caps, 2) << "both tube ends must be capped"; - EXPECT_EQ(boundaryEdgeCount(s), 0u) << "tube must be watertight after capping"; -} - // ---- solidify (#863 follow-up: give a thin shell real wall volume) --------- TEST(SubMeshOpsTest, SolidifyClosesAnOpenFlatQuadIntoASlab) diff --git a/src/commands/ExplodePartsCommand.cpp b/src/commands/ExplodePartsCommand.cpp index efd65366..a0eac5bd 100644 --- a/src/commands/ExplodePartsCommand.cpp +++ b/src/commands/ExplodePartsCommand.cpp @@ -33,11 +33,10 @@ void reparentAndSetLocal(Manager* mgr, Ogre::SceneNode* node, } // namespace ExplodePartsCommand::ExplodePartsCommand(std::string entityName, float distance, - bool capBoundaries, QUndoCommand* parent) + QUndoCommand* parent) : QUndoCommand(parent) , mEntityName(std::move(entityName)) , mDistance(distance) - , mCapBoundaries(capBoundaries) { setText(QStringLiteral("Explode into Parts")); } @@ -90,8 +89,7 @@ void ExplodePartsCommand::buildOnce() mParentNodeName = parent->getName(); PartOpsScene::ExplodeResult r = - PartOpsScene::explodeEntity(entity, mDistance, mEntityName + std::string("_part"), - mCapBoundaries); + PartOpsScene::explodeEntity(entity, mDistance, mEntityName + std::string("_part")); if (!r.ok) { mError = r.error.isEmpty() ? QStringLiteral("explode failed") : r.error; return; diff --git a/src/commands/ExplodePartsCommand.h b/src/commands/ExplodePartsCommand.h index bd66d5e7..a37a1ea9 100644 --- a/src/commands/ExplodePartsCommand.h +++ b/src/commands/ExplodePartsCommand.h @@ -37,10 +37,8 @@ class ExplodePartsCommand : public QUndoCommand { public: /** @param entityName the fused entity to explode (== its node name). - * @param distance explode offset multiplier (× assembly diagonal). - * @param capBoundaries close each part's open cut face (#863). */ + * @param distance explode offset multiplier (× assembly diagonal). */ ExplodePartsCommand(std::string entityName, float distance, - bool capBoundaries = false, QUndoCommand* parent = nullptr); void undo() override; @@ -58,7 +56,6 @@ class ExplodePartsCommand : public QUndoCommand std::string mEntityName; float mDistance = 0.5f; - bool mCapBoundaries = false; struct PartCache { Ogre::MeshPtr mesh; ///< single-submesh part mesh (resident for redo). diff --git a/src/commands/SplitMeshCommand.cpp b/src/commands/SplitMeshCommand.cpp index e69d2f9f..f0233f88 100644 --- a/src/commands/SplitMeshCommand.cpp +++ b/src/commands/SplitMeshCommand.cpp @@ -138,12 +138,9 @@ void SplitMeshCommand::redo() SubMeshOps::SplitOptions sopts; if (!mNamePrefix.isEmpty()) sopts.namePrefix = mNamePrefix; - // Close each part's open cut face so the user-facing parts are watertight - // solids — an exploded part otherwise shows a see-through hole where it - // was cut from its neighbour (#863). - sopts.capParts = true; // Optionally give thin-shell parts real wall volume so a cut exposes a - // solid wall instead of the hollow interior (#863 follow-up). + // solid wall instead of the hollow interior — this also seals each part + // watertight (#863 follow-up). sopts.solidifyParts = mSolidify; auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity(