diff --git a/CLAUDE.md b/CLAUDE.md index 43f83b39..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 `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 `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 46f53bb7..b163581c 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 d04755ea..206e62ff 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); @@ -10637,6 +10639,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.solidifyParts = solidify; // --solidify: wall volume for thin shells PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString()); if (!so.ok) { @@ -10646,10 +10649,12 @@ 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; } + const QString fmt = formatForExtension(outputPath); if (MeshImporterExporter::exporter( node, QFileInfo(outputPath).absoluteFilePath(), fmt) != 0) { diff --git a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp index 50d7466a..1931bc7e 100644 --- a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -33,6 +33,9 @@ #include "MeshSegmenter.h" #include "EditableMesh.h" +#include +#include + #include #include @@ -160,7 +163,8 @@ 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). + // 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_EQ(static_cast(info.triangles), srcTris); 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 82776ae9..c530b8e4 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 83189595..c97a6298 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/PartOpsMesh.cpp b/src/PartOpsMesh.cpp index cd0eb886..103f08ae 100644 --- a/src/PartOpsMesh.cpp +++ b/src/PartOpsMesh.cpp @@ -44,7 +44,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(); @@ -53,10 +54,9 @@ 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). + Ogre::MeshPtr mesh = em.createNewMesh(baseName, recomputeNormals); if (!mesh) return mesh; diff --git a/src/PartOpsMesh.h b/src/PartOpsMesh.h index a35b9870..b0481711 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/PartOpsScene.cpp b/src/PartOpsScene.cpp index ebf152f6..0437a0c4 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) { ExplodeResult out; if (!entity || !entity->getMesh()) { diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp index f0c5f911..8589e65a 100644 --- a/src/SubMeshOps.cpp +++ b/src/SubMeshOps.cpp @@ -7,35 +7,6 @@ #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) { @@ -305,6 +276,13 @@ SubMeshOps::splitByFaceGroups(const std::vector& subMeshes, return result; } + // 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); + result.duplicatedBoundaryVertices = duplicated; result.createdSubMeshes = static_cast(result.subMeshes.size()); result.ok = true; @@ -421,193 +399,100 @@ SubMeshOps::explodeOffsets(const std::vector& partCentroids, return offsets; } -SubMeshOps::BoundaryPlane -SubMeshOps::estimateBoundaryPlane(const std::vector& partA, - const std::vector& partB, float weldTol) +int SubMeshOps::solidify(EditableSubMesh& sub, float thickness) { - 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); - } + const unsigned int outerN = static_cast(sub.vertices.size()); + if (outerN == 0 || sub.triangles.empty()) + return 0; - if (pts.size() < 8) { - plane.reason = QStringLiteral("boundary too small (%1 shared verts, need >= 8)") - .arg(pts.size()); - return plane; + // 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; } - - // 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; + 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(); } } - 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; - plane.normal = eigvec[smallest].normalisedCopy(); - // In-plane radius: RMS distance to centroid projected off the normal. - 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(); + + // 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; } - 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; + // 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); } - if (!(plane.radius > 0.0f)) { - plane.reason = QStringLiteral("degenerate boundary radius"); - return plane; + 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); } - 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); + // 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; }; - 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); + 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])]++; } - 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; + 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; } - if (made > 0) { - outMale.materialName = "connector_male"; - outSocket.materialName = "connector_socket"; - } - return made; + // 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 65e90da1..3450b0ca 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 @@ -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; + /** 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; }; struct SplitResult { @@ -156,47 +164,25 @@ class SubMeshOps const Ogre::AxisAlignedBox& assemblyBounds, float distance); - // ------------------------------------------------------------------------- - // 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); + // 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 fd8577be..4d02d8a6 100644 --- a/src/SubMeshOps_test.cpp +++ b/src/SubMeshOps_test.cpp @@ -8,6 +8,8 @@ #include "MeshSegmenter.h" #include +#include +#include namespace { @@ -364,69 +366,58 @@ 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); -} +// ---- solidify watertightness helper -------------------------------------- -TEST(SubMeshOpsTest, BoundaryPlaneRejectsTinyBoundary) +// Count directed boundary edges (a→b with no b→a) — 0 means watertight. +static size_t boundaryEdgeCount(const EditableSubMesh& s) { - // 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)); + 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]}]++; } - auto plane = SubMeshOps::estimateBoundaryPlane({a}, {b}); - EXPECT_FALSE(plane.stable); - EXPECT_FALSE(plane.reason.isEmpty()); + size_t open = 0; + for (const auto& kv : d) + if (!d.count({kv.first.second, kv.first.first})) open += 1; + return open; } -TEST(SubMeshOpsTest, AlignmentPegsGeneratedOnStablePlane) +// ---- solidify (#863 follow-up: give a thin shell real wall volume) --------- + +TEST(SubMeshOpsTest, SolidifyClosesAnOpenFlatQuadIntoASlab) { - 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()); + // 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, AlignmentPegsSkippedOnUnstablePlane) +TEST(SubMeshOpsTest, SolidifyAutoThicknessAndNoOpOnEmpty) { - 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()); + 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 0bdce14a..f0233f88 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")); } @@ -136,6 +138,10 @@ void SplitMeshCommand::redo() SubMeshOps::SplitOptions sopts; if (!mNamePrefix.isEmpty()) sopts.namePrefix = mNamePrefix; + // Optionally give thin-shell parts real wall volume so a cut exposes a + // 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( 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.