diff --git a/CLAUDE.md b/CLAUDE.md index 3d4998db7..911ed1634 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,8 @@ qtmesh segment tree.glb --category vegetation # force a category (skip the poin qtmesh segment car.glb --category vehicle # vehicle_body/wheel/window/wing/rotor; `building` = wall/roof/window/door/chimney/foundation qtmesh segment model.fbx --no-model --up-axis y # force the deterministic geometric fallback (skip the ONNX models; auto → body) qtmesh segment rigged.fbx --dump-training-data sample.json # mine EXACT rig-prior labels from a SKINNED mesh → training sample (#410; feed to export-meshseg-onnx.py --real-data) +qtmesh segment model.fbx --write-labels labels.json # PartOps (#859/#861): dump per-vertex+per-face part labels (schema qtmesh-partops-labels-v1) without splitting +qtmesh segment model.fbx --split-parts -o parts.fbx # PartOps (#859/#861): split the segmented mesh into one named submesh per part (head/torso/left_arm/…); boundary verts duplicated so parts are independent; preserves normals/uv/colour/tangent + skeleton & bone weights (skinned meshes stay riggable). FBX keeps the submesh boundaries; glTF coalesces same-material parts. Add --no-model for the offline geometric/rig-prior path qtmesh mocap talk.mp4 --face --mesh avatar.glb -o out.glb # performance capture (#869, needs -DENABLE_MOCAP): facial expressions -> ARKit-blendshape weight keyframes + head rotation (Head bone or node); models download on first use qtmesh mocap dance.mp4 --body --mesh rigged.fbx -o out.glb # full-body pose -> skeletal clip on the humanoid rig (root locked; --algo sam3dbody|pose-ik, sam3dbody falls back to pose-ik while its checkpoints are gated; --no-model forces the fallback) qtmesh mocap take.mp4 --face --body --mesh char.glb -o out.glb # both in one decode pass ("_Body" for the body clip) @@ -406,6 +408,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. 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). Breadcrumbs `mesh.parts.segment_preview` / `mesh.parts.split_segments`. Body-centric labels; glTF coalesces same-material parts (FBX preserves them). Tests: `SubMeshOps_test.cpp`, `SplitMeshCommand_test.cpp`, `CLIPipeline_cmdsplitparts_coverage_test.cpp` (rigged round-trip: multi-submesh + tris + skeleton + names + unit-length normals). Remaining epic slices: C explode/join scene nodes, D print-peg dialog, E remaining MCP tools (explode/join/print), 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 eb5f786bf..889e85de2 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -849,6 +849,17 @@ Rectangle { Component.onCompleted: content = hdrEnvironmentComponent } + // ---- Split into Parts (AI segmentation, #859/#861, Object mode) ---- + CollapsibleSection { + title: "Split into Parts (AI)" + sectionVisible: root.modeToolSectionVisible( + EditorModeController.ObjectMode, + PartOpsController.hasSelection) + expanded: false + + Component.onCompleted: content = partOpsSplitComponent + } + // ---- Decimate (single-pass) ---- CollapsibleSection { title: "Decimate (single-pass)" @@ -6356,6 +6367,125 @@ Rectangle { // Live slider + preview that swaps a temporary LOD into the viewport, // mirroring the LOD section's previewLod pattern but for one-shot // base-mesh reduction. Apply commits the swap permanently. + // PartOps split (#859/#861): segment the selected fused mesh and replace + // it with one submesh per detected part (head/torso/…). Undoable (Ctrl+Z + // restores the fused mesh). Runs in Object mode; no Edit Mode required. + Component { + id: partOpsSplitComponent + + Column { + id: partOpsSplitContent + width: parent ? parent.width : 200 + padding: 8 + spacing: 6 + + // Category id list, index-aligned with partOpsCategoryCombo. + readonly property var partOpsCategories: + ["auto", "body", "vegetation", "vehicle", "building"] + + Text { + width: parent.width - 16 + wrapMode: Text.WordWrap + color: PropertiesPanelController.textColor + font.pixelSize: 11 + text: "Split the selected mesh into named part submeshes " + + "(head, torso, arms, legs). Undoable." + } + + Row { + spacing: 6 + Text { + text: "Category:" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + ThemedComboBox { + id: partOpsCategoryCombo + width: 140 + height: 22 + font.pixelSize: 11 + model: partOpsSplitContent.partOpsCategories + currentIndex: 0 + } + } + + // Checked = AI-assisted (the ONNX segmentation model, downloaded on + // first use). Unchecked = the deterministic geometric / rig-prior + // fallback. Both run locally; the model is the only thing that + // downloads. Default ON. Uses the inspector's own InspectorCheckBox + // so it matches the other panel toggles (not the Material-Editor + // Themed* look). + InspectorCheckBox { + id: partOpsAiCheck + text: "AI assisted" + checked: true + } + + // 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). + Rectangle { + id: partOpsSplitBtn + property bool clickEnabled: PartOpsController.hasSelection + width: Math.min(parent ? parent.width - 16 : 200, + partOpsSplitBtnLabel.implicitWidth + 20) + height: 26 + radius: 3 + opacity: clickEnabled ? 1.0 : 0.45 + color: partOpsSplitBtnMa.containsMouse && clickEnabled + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.headerColor + border.color: PropertiesPanelController.borderColor + border.width: 1 + Text { + id: partOpsSplitBtnLabel + anchors.centerIn: parent + text: "Split into Parts" + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: partOpsSplitBtnMa + anchors.fill: parent + hoverEnabled: true + enabled: partOpsSplitBtn.clickEnabled + cursorShape: partOpsSplitBtn.clickEnabled + ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + partOpsSplitFeedback.color = PropertiesPanelController.textColor + partOpsSplitFeedback.text = "Splitting…" + // noModel is the inverse of "AI assisted". + PartOpsController.splitSelectedIntoParts( + "y", + partOpsSplitContent.partOpsCategories[partOpsCategoryCombo.currentIndex], + !partOpsAiCheck.checked) + } + } + } + + Text { + id: partOpsSplitFeedback + width: parent.width - 16 + wrapMode: Text.WordWrap + color: PropertiesPanelController.textColor + font.pixelSize: 11 + text: "" + } + + Connections { + target: PartOpsController + function onSplitFinished(status, isError) { + partOpsSplitFeedback.color = isError ? "#e06060" : "#60c060" + partOpsSplitFeedback.text = status + } + function onSelectionChanged() { + partOpsSplitFeedback.text = "" + } + } + } + } + Component { id: decimateComponent diff --git a/src/Assimp/MeshProcessor.cpp b/src/Assimp/MeshProcessor.cpp index f38d705f0..ae9aa5bc6 100644 --- a/src/Assimp/MeshProcessor.cpp +++ b/src/Assimp/MeshProcessor.cpp @@ -55,6 +55,8 @@ void MeshProcessor::processNode(aiNode* node, const aiScene* scene) { SubMeshData* MeshProcessor::processMesh(aiMesh* mesh, const aiScene* scene) { SubMeshData* subMeshData = new SubMeshData(); + if (mesh->mName.length > 0) + subMeshData->name = mesh->mName.C_Str(); // Rotation applied to vertex data when the source file uses a Z-up coordinate system. // Baking it here avoids a scene-node rotation and keeps the entity in its natural pose. @@ -179,6 +181,30 @@ Ogre::MeshPtr MeshProcessor::createMesh(const Ogre::String& name, const Ogre::St // Create a submesh Ogre::SubMesh* subMesh = ogreMesh->createSubMesh(); + // Register the source name (aiMesh::mName) so named submeshes — e.g. + // PartOps parts "head"/"torso" round-tripped through FBX — are + // addressable by name and shown in the Scene tree. Skipped when the + // source mesh was unnamed OR the name is already taken: nameSubMesh + // overwrites the SubMeshNameMap entry, so a duplicate aiMesh::mName + // would make BOTH names resolve to the last submesh (CodeRabbit). On a + // collision we disambiguate with an index suffix instead of dropping + // the name, so every submesh stays addressable. + if (!subMeshData->name.empty()) { + const unsigned short idx = + static_cast(ogreMesh->getNumSubMeshes() - 1); + const Ogre::Mesh::SubMeshNameMap& nameMap = ogreMesh->getSubMeshNameMap(); + std::string name = subMeshData->name; + if (nameMap.find(name) != nameMap.end()) { + unsigned int suffix = 1; + std::string candidate; + do { + candidate = name + "_" + std::to_string(suffix++); + } while (nameMap.find(candidate) != nameMap.end()); + name = candidate; + } + ogreMesh->nameSubMesh(name, idx); + } + // Create the vertex data Ogre::VertexData* vertexData = new Ogre::VertexData(); subMesh->useSharedVertices = false; diff --git a/src/Assimp/MeshProcessor.h b/src/Assimp/MeshProcessor.h index a2504804c..038fe599a 100644 --- a/src/Assimp/MeshProcessor.h +++ b/src/Assimp/MeshProcessor.h @@ -25,6 +25,8 @@ struct SubMeshData { std::vector boneAssignments; std::vector morphTargets; ///< Empty when source had no blend shapes. unsigned int materialIndex; + std::string name; ///< From `aiMesh::mName`; drives Mesh::nameSubMesh so + ///< named submeshes (e.g. PartOps parts) survive import. }; class MeshProcessor { diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 1cdfa5df6..feeecd1da 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -50,6 +50,8 @@ #include "ImageTo3D/TripoSGPredictor.h" #include "ImageTo3D/MeshGenBuilder.h" #include "MeshSegmenter.h" +#include "SubMeshOps.h" +#include "PartOpsMesh.h" #include "MeshDecimator.h" #include "EditableMesh.h" #include "TexturePaintBuffer.h" @@ -10301,6 +10303,9 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) // [--dump-training-data ] QString inputPath; QString dumpPath; + 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 jsonOutput = false; bool noModel = false; int upAxis = 1; // +Y default @@ -10311,6 +10316,23 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) if (arg == "segment" || arg == "--cli") continue; if (arg == "--json") { jsonOutput = true; continue; } if (arg == "--no-model") { noModel = true; continue; } + if (arg == "--split-parts") { splitParts = true; continue; } + if (arg == "--write-labels") { + if (i + 1 >= argc) { + err() << "Error: --write-labels requires an output path." << Qt::endl; + return 2; + } + writeLabelsPath = QString::fromLocal8Bit(argv[++i]); + continue; + } + if (arg == "-o" || arg == "--output") { + if (i + 1 >= argc) { + err() << "Error: -o requires an output path." << Qt::endl; + return 2; + } + outputPath = QString::fromLocal8Bit(argv[++i]); + continue; + } if (arg == "--category") { if (i + 1 >= argc) { err() << "Error: --category requires a value (auto, body, " @@ -10354,11 +10376,16 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) err() << "Error: No input file specified." << Qt::endl; err() << "Usage: qtmesh segment [--json] [--no-model] [--up-axis x|y|z] " "[--category auto|body|vegetation|vehicle|building] " - "[--dump-training-data ]" << Qt::endl; + "[--dump-training-data ] [--write-labels ] " + "[--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: --split-parts requires -o ." << Qt::endl; + return 2; + } if (!initOgreHeadless()) return 1; SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.segment"), @@ -10517,6 +10544,87 @@ int CLIPipeline::cmdSegment(int argc, char* argv[]) {QStringLiteral("success"), true}, {QStringLiteral("capability"), QStringLiteral("segmentation")}}); + // --- PartOps: write labels (#864) -------------------------------------- + if (!writeLabelsPath.isEmpty()) { + QJsonObject root; + root["schema"] = QStringLiteral("qtmesh-partops-labels-v1"); + root["mesh"] = fi.fileName(); + root["category"] = MeshSegmenter::categoryName(r.category); + root["vertexCount"] = vertexCount; + root["faceCount"] = static_cast(r.faceLabels.size()); + QJsonArray vl, fl; + for (int l : r.vertexLabels) vl.append(l); + for (int l : r.faceLabels) fl.append(l); + root["vertexLabels"] = vl; + root["faceLabels"] = fl; + QJsonObject names; + for (int p = 0; p < P; ++p) + if (vCount[p] > 0 || fCount[p] > 0) + names[QString::number(p)] = MeshSegmenter::partName(p); + root["partNames"] = names; + QFile lf(writeLabelsPath); + if (!lf.open(QIODevice::WriteOnly)) { + err() << "Error: cannot write labels to " << writeLabelsPath << Qt::endl; + return 1; + } + lf.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); + lf.close(); + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.segment_preview"), + QStringLiteral("write-labels faces=%1") + .arg(r.faceLabels.size())); + if (!splitParts && !jsonOutput) + cliWrite(QString("Wrote labels: %1 (%2 faces)\n") + .arg(QFileInfo(writeLabelsPath).fileName()) + .arg(r.faceLabels.size())); + } + + // --- PartOps: split into per-part submeshes (#861/#864) ---------------- + if (splitParts) { + auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); + SubMeshOps::SplitOptions sopts; // default "Body" prefix, preserve material + PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( + entity, r.faceLabels, groups, sopts, fi.completeBaseName().toStdString()); + if (!so.ok) { + err() << "Error: split failed — " + << (so.error.isEmpty() ? QStringLiteral("unknown") : so.error) << Qt::endl; + return 1; + } + auto* mgr = Manager::getSingletonPtr(); + Ogre::SceneNode* node = mgr ? mgr->addSceneNode("PartOpsSplit") : nullptr; + if (!node || !mgr->createEntity(node, so.mesh)) { + 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) { + err() << "Error: export failed for " << outputPath << Qt::endl; + return 1; + } + SentryReporter::addBreadcrumb( + QStringLiteral("mesh.parts.split_segments"), + QStringLiteral("parts=%1 dupVerts=%2") + .arg(so.createdSubMeshes).arg(so.duplicatedBoundaryVertices)); + if (jsonOutput) { + QJsonObject root; + root["mesh"] = fi.fileName(); + root["output"] = QFileInfo(outputPath).fileName(); + root["createdSubMeshes"] = so.createdSubMeshes; + root["duplicatedBoundaryVertices"] = so.duplicatedBoundaryVertices; + QJsonArray pn; + for (const QString& n : so.partNames) pn.append(n); + root["partNames"] = pn; + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Compact)) + "\n"); + } else { + cliWrite(QString("Split %1 into %2 part submeshes → %3\n") + .arg(fi.fileName()).arg(so.createdSubMeshes) + .arg(QFileInfo(outputPath).fileName())); + for (const QString& n : so.partNames) + cliWrite(QString(" %1\n").arg(n)); + } + return 0; // split path produces its own output; skip the label dump below + } + if (jsonOutput) { QJsonObject root; root["mesh"] = fi.fileName(); diff --git a/src/CLIPipeline_cmdsplitparts_coverage_test.cpp b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp new file mode 100644 index 000000000..50d7466a1 --- /dev/null +++ b/src/CLIPipeline_cmdsplitparts_coverage_test.cpp @@ -0,0 +1,274 @@ +// PartOps Slice B/E (#861/#864): coverage for `qtmesh segment --split-parts` +// and `--write-labels`. Exercises the full core→Ogre pipeline headless: import +// a mesh, segment (geometric fallback via --no-model so no network / model +// download), split into per-part submeshes via SubMeshOps + PartOpsMesh, export, +// and re-import to assert the round-trip. Skinned-fixture bone preservation is +// asserted when a rigged asset is available. +// +// Ogre IS available in CI (Linux + Xvfb); SetUp asserts tryInitOgre() and never +// GTEST_SKIPs. When no rigged fixture is on disk the split still runs on a +// generated in-memory mesh so the suite always reaches a real assertion. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "CLIPipeline.h" +#include "Manager.h" +#include "MeshImporterExporter.h" +#include "TestHelpers.h" +#include "MeshSegmenter.h" +#include "EditableMesh.h" + +#include +#include + +namespace { + +class SplitArgv { +public: + SplitArgv(std::initializer_list args) + { + for (auto* a : args) + m_storage.push_back(QByteArray(a)); + for (auto& ba : m_storage) + m_argv.push_back(ba.data()); + m_argc = static_cast(m_argv.size()); + } + int argc() const { return m_argc; } + char** argv() { return m_argv.data(); } + +private: + QList m_storage; + QList m_argv; + int m_argc = 0; +}; + +void clearScene() +{ + if (!Manager::getSingletonPtr()) + return; + auto nodes = Manager::getSingleton()->getSceneNodes(); + for (auto* node : nodes) { + Manager::getSingleton()->destroyAllAttachedMovableObjects(node); + Manager::getSingleton()->destroySceneNode(node); + } +} + +int meshTriangleCount(const QString& path) +{ + clearScene(); + MeshImporterExporter::importer({QFileInfo(path).absoluteFilePath()}); + auto& entities = Manager::getSingleton()->getEntities(); + if (entities.isEmpty()) + return -1; + MeshInfo info = CLIPipeline::extractMeshInfo(entities.first(), QFileInfo(path).fileName()); + return static_cast(info.triangles); +} + +} // namespace + +class CLIPipelineCmdSplitPartsCoverageTest : public ::testing::Test { +protected: + void SetUp() override + { + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + ASSERT_TRUE(canLoadMeshFiles()); + createStandardOgreMaterials(); + ASSERT_TRUE(CLIPipeline::initOgreHeadless()); + clearScene(); + } + void TearDown() override { clearScene(); } + + /// A rigged humanoid fixture if present (Rumba Dancing.fbx), else empty. + static QString riggedFixture() + { + const QString p = testAssetPath(QStringLiteral("media/models/Rumba Dancing.fbx")); + return (!p.isEmpty() && QFile::exists(p)) ? p : QString(); + } + + /// A generated in-memory triangle mesh exported to .mesh — the always-there + /// fallback so the suite never skips. + static QString generatedMesh(QTemporaryDir& holder) + { + auto* mgr = Manager::getSingletonPtr(); + if (!mgr || !holder.isValid()) + return QString(); + const std::string meshName = "cli_split_gen_mesh"; + Ogre::MeshPtr mesh = createInMemoryTriangleMesh(meshName); + Ogre::SceneNode* node = mgr->addSceneNode("cli_split_gen_node"); + if (!node) + return QString(); + Ogre::Entity* e = mgr->createEntity(node, mesh); + if (!e) + return QString(); + const QString out = QDir(holder.path()).filePath("cli_split_gen.mesh"); + const int rc = MeshImporterExporter::exporter(node, out, "Ogre Mesh (*.mesh)"); + mgr->destroyAllAttachedMovableObjects(node); + mgr->destroySceneNode(node); + if (auto old = Ogre::MeshManager::getSingleton().getByName(meshName)) + Ogre::MeshManager::getSingleton().remove(old); + return rc == 0 ? out : QString(); + } +}; + +// --split-parts on a rigged humanoid: produces a multi-submesh mesh, preserves +// the triangle count, and keeps the skeleton (skinned bone assignments). +TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitRiggedHumanoidPreservesTrisAndSkeleton) +{ + const QString fixture = riggedFixture(); + if (fixture.isEmpty()) + GTEST_SKIP() << "rigged fixture not present; covered by generated-mesh test"; + + const int srcTris = meshTriangleCount(fixture); + ASSERT_GT(srcTris, 0); + + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString outFbx = QDir(tmp.path()).filePath("parts.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)); + + // Re-import the split result and inspect it. + clearScene(); + MeshImporterExporter::importer({QFileInfo(outFbx).absoluteFilePath()}); + auto& entities = Manager::getSingleton()->getEntities(); + ASSERT_FALSE(entities.isEmpty()); + Ogre::Entity* e = entities.first(); + ASSERT_NE(e, nullptr); + + // More than one submesh (a fused body split into parts). + EXPECT_GT(e->getMesh()->getNumSubMeshes(), 1u) + << "split should produce multiple part submeshes"; + + // Triangle count preserved (boundary duplication adds verts, not tris). + MeshInfo info = CLIPipeline::extractMeshInfo(e, "parts.fbx"); + EXPECT_EQ(static_cast(info.triangles), srcTris); + + // Skinned fixture retains its skeleton + bone assignments (#861 criterion). + EXPECT_TRUE(e->getMesh()->hasSkeleton()) + << "split of a skinned mesh must keep the skeleton bound"; + + // Normals are preserved as valid unit vectors — NOT zeroed/degenerate. + // The split builds with recomputeNormals=false (to keep authored normals), + // so a bug there would leave black geometry (the "model is dark" symptom). + // Read the reimported normals back and assert the vast majority are + // unit-length; a handful of legitimately-degenerate verts is tolerated. + { + EditableMesh em; + ASSERT_TRUE(em.loadFromEntity(e)); + int total = 0, unitLen = 0, zeroLen = 0; + for (const auto& sm : em.subMeshes()) { + for (const auto& v : sm.vertices) { + if (!v.hasNormal) continue; + ++total; + const float len = v.normal.length(); + if (len < 1e-4f) ++zeroLen; + else if (std::fabs(len - 1.0f) < 0.05f) ++unitLen; + } + } + ASSERT_GT(total, 0) << "reimported split has no normals — would render black"; + EXPECT_LT(zeroLen, total / 100 + 1) << "too many zero-length normals"; + EXPECT_GT(unitLen, total * 9 / 10) + << "split normals must stay unit-length so lighting works (dark-model regression)"; + } + + // Part NAMES survive the FBX export → reimport round-trip: the mesh's + // submesh-name map is non-empty and every name is a known body part + // (so the Scene tree shows "head"/"torso"/… not a positional index). + const auto& nameMap = e->getMesh()->getSubMeshNameMap(); + EXPECT_FALSE(nameMap.empty()) + << "split part names must round-trip through FBX as named submeshes"; + std::set seenNames; + for (const auto& kv : nameMap) { + // Every registered submesh name must be UNIQUE — nameSubMesh overwrites + // on collision, so a duplicate would make two submeshes resolve to one. + // The importer disambiguates same aiMesh::mName with an "_N" suffix. + EXPECT_TRUE(seenNames.insert(kv.first).second) + << "duplicate submesh name registered: " << kv.first; + // Strip a trailing ".N" (multi-material) or "_N" (import-collision) + // NUMERIC suffix before matching. Part names themselves contain '_' + // (e.g. "right_leg"), so only a trailing all-digit segment after the + // LAST '.'/'_' is a disambiguation suffix — not the base name's own '_'. + QString base = QString::fromStdString(kv.first); + for (const QChar sep : {QLatin1Char('.'), QLatin1Char('_')}) { + const int at = base.lastIndexOf(sep); + if (at > 0) { + const QString tail = base.mid(at + 1); + bool allDigits = !tail.isEmpty(); + for (const QChar c : tail) + if (!c.isDigit()) { allDigits = false; break; } + if (allDigits) + base = base.left(at); + } + } + bool known = false; + for (int p = 1; p < MeshSegmenter::partCount(); ++p) { + if (base == MeshSegmenter::partName(p)) { known = true; break; } + } + EXPECT_TRUE(known) << "unexpected submesh name: " << kv.first; + } +} + +// --split-parts without -o is a usage error (exit 2), no Ogre load required. +TEST_F(CLIPipelineCmdSplitPartsCoverageTest, SplitPartsRequiresOutput) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString mesh = generatedMesh(tmp); + ASSERT_FALSE(mesh.isEmpty()); + const QByteArray in = mesh.toUtf8(); + SplitArgv args({"qtmesh", "segment", in.constData(), "--no-model", "--split-parts"}); + EXPECT_EQ(2, CLIPipeline::cmdSegment(args.argc(), args.argv())); +} + +// --write-labels dumps a valid labels JSON with the documented schema + arrays. +TEST_F(CLIPipelineCmdSplitPartsCoverageTest, WriteLabelsProducesSchemaJson) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + const QString mesh = generatedMesh(tmp); + ASSERT_FALSE(mesh.isEmpty()); + + const QString labels = QDir(tmp.path()).filePath("labels.json"); + const QByteArray in = mesh.toUtf8(); + const QByteArray lb = labels.toUtf8(); + SplitArgv args({"qtmesh", "segment", in.constData(), "--no-model", + "--write-labels", lb.constData()}); + ASSERT_EQ(0, CLIPipeline::cmdSegment(args.argc(), args.argv())); + ASSERT_TRUE(QFile::exists(labels)); + + QFile f(labels); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + QJsonParseError perr{}; + QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &perr); + ASSERT_EQ(perr.error, QJsonParseError::NoError); + ASSERT_TRUE(doc.isObject()); + QJsonObject o = doc.object(); + EXPECT_EQ(o.value("schema").toString(), QStringLiteral("qtmesh-partops-labels-v1")); + EXPECT_TRUE(o.contains("faceLabels")); + EXPECT_TRUE(o.contains("vertexLabels")); + EXPECT_TRUE(o.value("faceLabels").isArray()); + EXPECT_GT(o.value("faceCount").toInt(), 0); + // faceLabels length matches faceCount. + EXPECT_EQ(o.value("faceLabels").toArray().size(), o.value("faceCount").toInt()); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9e643b8f9..a43831891 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -94,6 +94,7 @@ commands/PoseLibraryCommands.cpp commands/SkeletonResolver.cpp commands/ComputeSkinWeightsCommand.cpp commands/AutoRigCommand.cpp +commands/SplitMeshCommand.cpp commands/SkeletonBoneCommands.cpp commands/UVEditCommand.cpp commands/UvSeamCommands.cpp @@ -239,6 +240,9 @@ EditableMesh.cpp EditModeController.cpp EditorModeController.cpp HalfEdgeMesh.cpp +SubMeshOps.cpp +PartOpsMesh.cpp +PartOpsController.cpp ) set(HEADER_FILES @@ -368,6 +372,7 @@ DrawCallAnalyzer.h VertexCacheOptimizer.h MeshDecimator.h MeshDecimatorController.h +PartOpsController.h MeshValidator.h AIChatManager.h AIModelCatalog.h diff --git a/src/EditModeController.cpp b/src/EditModeController.cpp index c32e8da01..8de6c522f 100644 --- a/src/EditModeController.cpp +++ b/src/EditModeController.cpp @@ -183,6 +183,17 @@ EditModeController::EditModeController() // Track selection changes to update canEnterEditMode and auto-exit connect(SelectionSet::getSingleton(), &SelectionSet::selectionChanged, this, &EditModeController::onSelectionChanged); + + // PartOps Slice A (#860): any topology-mutating edit emits meshDataChanged + // (vertex-only commits do too — clearing then is merely conservative, + // re-running segmentation is cheap). This invalidates the cached per- + // triangle labels whose triangle-stream mapping the edit just reshuffled. + // Segmentation's own selection path emits editSelectionChanged, NOT + // meshDataChanged, so it never self-clears. rewriteEntityAfterTopologyChange + // is static (no `this`), so the invalidation lives here, at the one + // non-static signal every topology op already fires. + connect(this, &EditModeController::meshDataChanged, + this, &EditModeController::clearSegmentationCache); } EditModeController::~EditModeController() @@ -651,6 +662,10 @@ void EditModeController::exitEditMode(bool commitChanges) m_editEntity = nullptr; m_editModeActive = false; + // PartOps Slice A (#860): stale segmentation state must not survive an + // edit-mode exit (the next mesh has a different topology / label mapping). + clearSegmentationCache(); + // Reset validation state for next session m_degenerateTriangleCount = 0; @@ -1074,6 +1089,16 @@ void EditModeController::finishSegmentOnMain(const std::vector& faceLabels, return; } + // PartOps Slice A (#860): cache the labels so the preview workflow reuses + // them without rerunning the model. A fresh run resets user rename/exclude/ + // hide state (they were keyed to the previous label set). + m_segLabels.assign(faceLabels.begin(), + faceLabels.begin() + totalTris); + m_partExcluded.clear(); + m_partHidden.clear(); + m_partDisplayName.clear(); + rebuildPartGroupsFromLabels(); + // Which labels to select: labels of currently-selected faces, or (nothing // selected) the single largest predicted part so one click is useful. std::set wantLabels; @@ -1145,6 +1170,147 @@ void EditModeController::finishSegmentOnMain(const std::vector& faceLabels, false); } +// ---- PartOps Slice A: segmentation preview & editable part groups (#860) ---- + +void EditModeController::rebuildPartGroupsFromLabels() +{ + m_partGroups.clear(); + if (!m_segLabels.empty()) { + std::map counts; // label -> face count, ordered by label + for (int l : m_segLabels) + ++counts[l]; + m_partGroups.reserve(counts.size()); + for (const auto& kv : counts) + m_partGroups.push_back(PartGroupState{kv.first, kv.second}); + } + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.segment_preview"), + QStringLiteral("groups=%1").arg(m_partGroups.size())); + emit partGroupsChanged(); +} + +void EditModeController::clearSegmentationCache() +{ + // Cancel any in-flight segmentation worker so it can't repopulate the cache + // with labels indexed to the OLD triangle stream after a topology change + // (CodeRabbit: if the edit leaves the triangle count unchanged, the + // completion size-check would otherwise pass and map stale labels onto the + // reshuffled faces). The worker's completion handler already no-ops on a + // set cancel flag; finishSegmentOnMain (the rig fast-path) checks it too. + if (m_segmentCancel) + m_segmentCancel->store(true); + + if (m_segLabels.empty() && m_partGroups.empty()) + return; + m_segLabels.clear(); + m_partGroups.clear(); + m_partExcluded.clear(); + m_partHidden.clear(); + m_partDisplayName.clear(); + emit partGroupsChanged(); +} + +QVariantList EditModeController::partGroups() const +{ + QVariantList out; + for (const PartGroupState& g : m_partGroups) { + QVariantMap m; + m[QStringLiteral("label")] = g.label; + const QString defName = MeshSegmenter::partName(g.label); + m[QStringLiteral("name")] = defName; + const auto dn = m_partDisplayName.find(g.label); + m[QStringLiteral("displayName")] = dn != m_partDisplayName.end() ? dn->second : defName; + m[QStringLiteral("faceCount")] = g.faceCount; + m[QStringLiteral("excluded")] = m_partExcluded.count(g.label) > 0; + m[QStringLiteral("hidden")] = m_partHidden.count(g.label) > 0; + out.append(m); + } + return out; +} + +int EditModeController::selectPartGroup(int label, bool addToSelection) +{ + if (m_segLabels.empty() || !m_editModeActive || !m_editableMesh) + return 0; + const int totalTris = static_cast(m_editableMesh->totalTriangleCount()); + if (static_cast(m_segLabels.size()) < totalTris) + return 0; + + if (m_selectionMode != FaceMode) + setSelectionMode(static_cast(FaceMode)); + if (!addToSelection) { + m_selectedVertices.clear(); + m_selectedEdges.clear(); + m_selectedFaces.clear(); + } + + // De-dup polygon expansion exactly like finishSegmentOnMain. + std::unordered_set seenPolygons; + int selectedPolygons = 0; + for (int t = 0; t < totalTris; ++t) { + if (m_segLabels[t] != label) + continue; + auto [subIdx, localTri] = globalTriToLocal(t); + if (subIdx >= m_editableMesh->subMeshes().size()) + continue; + const auto& sub = m_editableMesh->subMeshes()[subIdx]; + size_t faceFirstTri = localTri, faceTriCount = 1; + faceIndexForTriangle(sub, localTri, &faceFirstTri, &faceTriCount); + const int faceKey = localTriToGlobal(subIdx, faceFirstTri); + if (!seenPolygons.insert(faceKey).second) + continue; + selectFace(t, /*addToSelection=*/true, /*notify=*/false); + ++selectedPolygons; + } + updateSelectionOverlay(); + emit editSelectionChanged(); + // Breadcrumb the part id (a stable enum name), never a user string. + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("part_group_select %1") + .arg(MeshSegmenter::partName(label))); + return selectedPolygons; +} + +void EditModeController::setPartGroupHidden(int label, bool hidden) +{ + const bool changed = hidden ? m_partHidden.insert(label).second + : (m_partHidden.erase(label) > 0); + if (changed) { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("part_group_%1 %2") + .arg(hidden ? QStringLiteral("hide") + : QStringLiteral("show")) + .arg(MeshSegmenter::partName(label))); + emit partGroupsChanged(); + } +} + +void EditModeController::setPartGroupExcluded(int label, bool excluded) +{ + const bool changed = excluded ? m_partExcluded.insert(label).second + : (m_partExcluded.erase(label) > 0); + if (changed) { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("part_group_%1 %2") + .arg(excluded ? QStringLiteral("exclude") + : QStringLiteral("include")) + .arg(MeshSegmenter::partName(label))); + emit partGroupsChanged(); + } +} + +void EditModeController::setPartGroupDisplayName(int label, const QString& name) +{ + if (name.trimmed().isEmpty()) + m_partDisplayName.erase(label); + else + m_partDisplayName[label] = name.trimmed(); + // NB: only the part id, NOT the user-provided display name (no content leak). + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("part_group_rename %1") + .arg(MeshSegmenter::partName(label))); + emit partGroupsChanged(); +} + void EditModeController::cancelSegment() { SentryReporter::addBreadcrumb(QStringLiteral("ai.assist.segment"), diff --git a/src/EditModeController.h b/src/EditModeController.h index 06425abc1..f203be627 100644 --- a/src/EditModeController.h +++ b/src/EditModeController.h @@ -698,6 +698,42 @@ class EditModeController : public QObject /// Cancel an in-flight selectByPart worker (no-op otherwise). Q_INVOKABLE void cancelSegment(); + /// @name PartOps segmentation preview (#860) + /// @{ + /// True once a segmentation has run and its labels are cached for reuse. + bool hasSegmentation() const { return !m_segLabels.empty(); } + + /// The detected part groups as a QVariantList of maps for QML: + /// `{ label:int, name:string, displayName:string, faceCount:int, + /// excluded:bool, hidden:bool }`, sorted by label (unknown first). + /// Empty until a segmentation runs. + Q_INVOKABLE QVariantList partGroups() const; + + /// Select every face of the group with the given part label (replacing the + /// current selection unless `addToSelection`). Switches to Face mode. + /// No-op without a cached segmentation. Returns the number of POLYGONS + /// selected (n-gons count once, not per fan-triangle). + Q_INVOKABLE int selectPartGroup(int label, bool addToSelection = false); + + /// Hide / show a group's faces in the viewport overlay preview. Purely + /// visual; does not affect split/explode. (Hidden groups render dimmed via + /// the segmentation preview overlay.) + Q_INVOKABLE void setPartGroupHidden(int label, bool hidden); + + /// Exclude / include a group from downstream split & explode operations. + Q_INVOKABLE void setPartGroupExcluded(int label, bool excluded); + + /// Override a group's display name (does not change the underlying part + /// label used for downstream submesh naming unless the caller opts in). + Q_INVOKABLE void setPartGroupDisplayName(int label, const QString& name); + + /// The cached per-global-triangle labels (empty when no segmentation). + /// Used by the split/explode adapters (Slices B/C) and CLI/MCP tests. + const std::vector& cachedFaceLabels() const { return m_segLabels; } + /// Labels the user excluded from downstream ops. + const std::set& excludedPartLabels() const { return m_partExcluded; } + /// @} + bool segmentBusy() const { return m_segmentBusy; } bool segmentDownloading() const { return m_segmentDownloading; } int segmentProgress() const { return m_segmentProgress; } // 0..segmentTotal @@ -893,6 +929,10 @@ class EditModeController : public QObject void segmentProgressChanged(); /// Final result of an async selectByPart() (status string; isError flag). void segmentFinished(const QString& status, bool isError); + /// PartOps Slice A (#860): the cached part-group list changed (a + /// segmentation completed, a group was renamed/excluded/hidden, or the + /// cache was cleared). QML rebinds `partGroups`. + void partGroupsChanged(); /// Emitted when entering or exiting edit mode. void editModeChanged(); /// Emitted when a morph sculpt session starts/ends (#519). @@ -985,6 +1025,27 @@ private slots: void finishSegmentOnMain(const std::vector& faceLabels, bool usedModel, const QString& predictError); + // PartOps Slice A (#860): cache the last segmentation's per-face labels so + // the preview workflow (select/hide/rename/exclude a group, then split / + // explode downstream) reuses them WITHOUT rerunning the model. Cleared on + // edit-mode exit and whenever topology changes invalidate the mapping. + // `m_segLabels` is one label per GLOBAL triangle (parallel to the flat + // triangle stream, submesh-then-local). `m_partGroups` is the grouped view + // exposed to QML. `m_partExcluded[label]` marks a group the user removed + // from downstream split/explode. `m_partDisplayName[label]` overrides the + // default part name for display. + std::vector m_segLabels; + struct PartGroupState { + int label = 0; + int faceCount = 0; + }; + std::vector m_partGroups; // sorted by label + std::set m_partExcluded; // labels excluded downstream + std::map m_partDisplayName; // label -> user rename + std::set m_partHidden; // labels hidden in the viewport + void rebuildPartGroupsFromLabels(); // (re)derive m_partGroups + emit + void clearSegmentationCache(); // drop labels/groups/state + emit + // Selection overlay Ogre::ManualObject* m_overlayVertices = nullptr; Ogre::ManualObject* m_overlayEdges = nullptr; diff --git a/src/EditableMesh.cpp b/src/EditableMesh.cpp index 2fff1dfbd..be47ea8e6 100644 --- a/src/EditableMesh.cpp +++ b/src/EditableMesh.cpp @@ -1406,13 +1406,17 @@ bool EditableMesh::resizeEntityBuffers(Ogre::Entity* entity) return true; } -Ogre::MeshPtr EditableMesh::createNewMesh(const std::string& baseName) +Ogre::MeshPtr EditableMesh::createNewMesh(const std::string& baseName, bool recomputeNormals) { - // Recalculate normals - if (m_flatNormals) - recalculateNormalsFlat(); - else - recalculateNormals(); + // Recalculate normals unless the caller already carries authored normals + // (PartOps split copies source normals verbatim — recomputing would flatten + // hard edges / custom shading, #859 review). + if (recomputeNormals) { + if (m_flatNormals) + recalculateNormalsFlat(); + else + recalculateNormals(); + } // Generate a unique name static int counter = 0; diff --git a/src/EditableMesh.h b/src/EditableMesh.h index e9e87fec6..8fb2c2298 100644 --- a/src/EditableMesh.h +++ b/src/EditableMesh.h @@ -506,9 +506,15 @@ class EditableMesh * resizeEntityBuffers() instead to keep Entity SubEntity caches valid. * * @param baseName Base name for the new mesh (a suffix is appended for uniqueness). + * @param recomputeNormals When true (default), per-vertex normals are + * recalculated before upload — the legacy behaviour for edit-mode + * topology ops that leave normals stale. Pass false to UPLOAD the + * existing `EditableVertex::normal` values verbatim; callers that + * copied authored / hard-edge normals from a source mesh (PartOps + * split) must do this or shading changes on the output (#859 review). * @return The new MeshPtr, or null on failure. */ - Ogre::MeshPtr createNewMesh(const std::string& baseName); + Ogre::MeshPtr createNewMesh(const std::string& baseName, bool recomputeNormals = true); /** * @brief Resize and update existing Ogre::Mesh buffers in-place. diff --git a/src/FBX/FBXExporter.cpp b/src/FBX/FBXExporter.cpp index 5048bef02..f4a21832f 100644 --- a/src/FBX/FBXExporter.cpp +++ b/src/FBX/FBXExporter.cpp @@ -1003,6 +1003,21 @@ class FBXDocumentBuilder } // ── Geometry objects (one per submesh) ──────────────────────── + // FBX object name for a submesh: prefer the Ogre registered submesh name + // (Mesh::nameSubMesh — e.g. PartOps "head"/"torso") so a split's part names + // round-trip through export → Assimp reimport (which reads aiMesh::mName) + // and show up in the Scene tree. Falls back to the legacy positional name. + std::string submeshFbxName(unsigned int si) const + { + if (m_mesh) { + for (const auto& kv : m_mesh->getSubMeshNameMap()) { + if (kv.second == si && !kv.first.empty()) + return kv.first; + } + } + return std::string(m_entity->getName()) + "_submesh" + std::to_string(si); + } + void writeGeometryObjects() { for (unsigned int si = 0; si < m_mesh->getNumSubMeshes(); ++si) @@ -1016,8 +1031,7 @@ class FBXDocumentBuilder m_geomIds.push_back(geomId); m_geomSubmeshIndices.push_back(si); - std::string geomName = std::string(m_entity->getName()) + - "_submesh" + std::to_string(si); + std::string geomName = submeshFbxName(si); m_w.beginNode("Geometry"); m_w.writePropertyL(geomId); @@ -1390,8 +1404,7 @@ class FBXDocumentBuilder int64_t modelId = nextId(); m_meshModelIds.push_back(modelId); - std::string modelName = std::string(m_entity->getName()) + - "_submesh" + std::to_string(si); + std::string modelName = submeshFbxName(si); m_w.beginNode("Model"); m_w.writePropertyL(modelId); diff --git a/src/MCPServer.cpp b/src/MCPServer.cpp index 492d76a4f..de6773213 100644 --- a/src/MCPServer.cpp +++ b/src/MCPServer.cpp @@ -133,6 +133,9 @@ #include "AnimationControlController.h" #include "SubMeshTransform.h" #include "UndoManager.h" +#include "SubMeshOps.h" +#include "PartOpsMesh.h" +#include "commands/SplitMeshCommand.h" #include "commands/TransformCommands.h" #ifdef Q_OS_WIN @@ -668,6 +671,7 @@ const QMap& MCPServer::toolHandlers() {QStringLiteral("adjust_arm_space"), &MCPServer::toolAdjustArmSpace}, {QStringLiteral("pin_feet"), &MCPServer::toolPinFeet}, {QStringLiteral("segment_mesh"), &MCPServer::toolSegmentMesh}, + {QStringLiteral("split_mesh_by_segments"), &MCPServer::toolSplitMeshBySegments}, {QStringLiteral("generate_mesh_from_image"), &MCPServer::toolGenerateMeshFromImage}, {QStringLiteral("save_scene"), &MCPServer::toolSaveScene}, {QStringLiteral("open_scene"), &MCPServer::toolOpenScene}, @@ -762,6 +766,7 @@ bool MCPServer::isHeavyTool(const QString &name) QStringLiteral("motion_in_between"), QStringLiteral("generate_motion"), QStringLiteral("segment_mesh"), + QStringLiteral("split_mesh_by_segments"), QStringLiteral("add_arkit_blendshapes"), QStringLiteral("generate_mesh_from_image"), QStringLiteral("save_scene"), @@ -837,6 +842,7 @@ QJsonObject MCPServer::callTool(const QString &name, const QJsonObject &args) {QStringLiteral("generate_motion"), QStringLiteral("animation_blend")}, {QStringLiteral("merge_animations"), QStringLiteral("animation_blend")}, {QStringLiteral("segment_mesh"), QStringLiteral("ai_assist")}, + {QStringLiteral("split_mesh_by_segments"), 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")}, @@ -4657,6 +4663,67 @@ QJsonObject MCPServer::toolSegmentMesh(const QJsonObject &args) } } +QJsonObject MCPServer::toolSplitMeshBySegments(const QJsonObject &args) +{ + // PartOps split (#859/#861/#864): segment the selected/named entity and + // replace it with one submesh per detected part, via the SAME undoable + // SplitMeshCommand the GUI button uses (so Ctrl+Z / undo works identically). + 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)); + + int axis = 1; + const QString upAxisStr = args.value("up_axis").toString().toLower(); + if (upAxisStr == "x") axis = 0; + else if (upAxisStr == "z") axis = 2; + else if (!upAxisStr.isEmpty() && upAxisStr != "y") + return makeErrorResult("Error: up_axis must be 'x', 'y', or 'z'"); + + const QString category = args.value("category").toString().isEmpty() + ? QStringLiteral("auto") : args.value("category").toString(); + const bool noModel = args.value("no_model").toBool(false); + + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.split_segments"), + QStringLiteral("MCP split_mesh_by_segments")); + + // Capture the entity NAME before push(): push runs redo() synchronously, + // which destroys this Ogre::Entity (mesh swap). Reading entity->getName() + // 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")); + UndoManager::getSingleton()->push(cmd); // runs redo() synchronously + if (!cmd->ok()) + return makeErrorResult(cmd->error().isEmpty() + ? QString("Error: split failed") : ("Error: " + cmd->error())); + + QJsonObject o; + o["entity"] = entityNameOut; + o["createdSubMeshes"] = cmd->createdSubMeshes(); + QJsonArray names; + for (const QString& n : cmd->partNames()) names.append(n); + o["partNames"] = names; + 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 { @@ -8929,6 +8996,26 @@ QJsonArray MCPServer::buildToolsList() ); } + // split_mesh_by_segments (#859/#861): PartOps split into per-part submeshes. + { + QJsonObject props; + props["entity_name"] = QJsonObject{{"type", "string"}, {"description", "Entity to split. Empty → the first mesh entity in the scene."}}; + 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')."}}; + appendTool( + "split_mesh_by_segments", + "PartOps split (#859/#861): segment the selected/named mesh and REPLACE " + "it with one named submesh per detected part (head/torso/left_arm/…). " + "Boundary vertices are duplicated so parts are independent; normals, " + "UVs, colours, tangents, the skeleton and bone weights are preserved. " + "Undoable (same command as the GUI 'Split into Parts' button). Returns " + "the created submesh count + part names. FBX export keeps the submesh " + "boundaries; glTF coalesces same-material parts.", + 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 28d133e31..da1db244d 100644 --- a/src/MCPServer.h +++ b/src/MCPServer.h @@ -216,6 +216,7 @@ private slots: QJsonObject toolAdjustArmSpace(const QJsonObject &args); // #854 arm-space QJsonObject toolPinFeet(const QJsonObject &args); // #856 foot-contact pin QJsonObject toolSegmentMesh(const QJsonObject &args); + QJsonObject toolSplitMeshBySegments(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 new file mode 100644 index 000000000..5f44e28b1 --- /dev/null +++ b/src/PartOpsController.cpp @@ -0,0 +1,81 @@ +#include "PartOpsController.h" + +#include "SelectionSet.h" +#include "UndoManager.h" +#include "SentryReporter.h" +#include "commands/SplitMeshCommand.h" + +#include + +PartOpsController* PartOpsController::m_pSingleton = nullptr; + +PartOpsController* PartOpsController::instance() +{ + if (!m_pSingleton) + m_pSingleton = new PartOpsController(); + return m_pSingleton; +} + +PartOpsController* PartOpsController::qmlInstance(QQmlEngine* engine, QJSEngine*) +{ + Q_UNUSED(engine); + auto* inst = instance(); + QQmlEngine::setObjectOwnership(inst, QQmlEngine::CppOwnership); + return inst; +} + +void PartOpsController::kill() +{ + delete m_pSingleton; + m_pSingleton = nullptr; +} + +PartOpsController::PartOpsController() : QObject(nullptr) +{ + if (auto* sel = SelectionSet::getSingleton()) + connect(sel, &SelectionSet::selectionChanged, this, &PartOpsController::selectionChanged); +} + +bool PartOpsController::hasSelection() const +{ + const auto* sel = SelectionSet::getSingleton(); + return sel && sel->getResolvedEntities().size() == 1; +} + +void PartOpsController::splitSelectedIntoParts(const QString& upAxis, const QString& category, + bool noModel) +{ + const auto* sel = SelectionSet::getSingleton(); + if (!sel) { + emit splitFinished(tr("No selection."), true); + return; + } + const QList entities = sel->getResolvedEntities(); + if (entities.size() != 1 || !entities.first()) { + emit splitFinished(tr("Select a single mesh to split."), true); + return; + } + + int axis = 1; + const QString a = upAxis.trimmed().toLower(); + if (a == QStringLiteral("x")) axis = 0; + else if (a == QStringLiteral("z")) axis = 2; + + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("split_into_parts%1") + .arg(noModel ? QStringLiteral(" offline") : QString())); + const std::string entName = entities.first()->getName(); + // 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")); + UndoManager::getSingleton()->push(cmd); + + if (!cmd->ok()) { + emit splitFinished(cmd->error().isEmpty() ? tr("Split failed.") : cmd->error(), true); + return; + } + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.split_segments"), + QStringLiteral("gui parts=%1").arg(cmd->createdSubMeshes())); + emit splitFinished(tr("Split into %1 part submeshes.").arg(cmd->createdSubMeshes()), false); +} diff --git a/src/PartOpsController.h b/src/PartOpsController.h new file mode 100644 index 000000000..a059503df --- /dev/null +++ b/src/PartOpsController.h @@ -0,0 +1,59 @@ +#ifndef PARTOPSCONTROLLER_H +#define PARTOPSCONTROLLER_H + +#include +#include +#include + +/** + * PartOps GUI controller (#859/#861): the Object-mode "Split into Parts" + * action. QML_SINGLETON, mirroring MeshDecimatorController. + * + * splitSelectedIntoParts() segments the selected fused mesh (rig-prior / ONNX + * / geometric, same pipeline as the CLI) and replaces it with a mesh whose + * submeshes are the detected parts (head/torso/…), via an undoable + * SplitMeshCommand (Ctrl+Z restores the fused mesh). Self-contained: no Edit + * Mode required — the split changes the submesh count, which the edit-mode + * in-place path forbids, so it swaps the whole mesh on the scene node. + * + * Synchronous today: the default GUI use (a rigged character, or the offline + * geometric fallback) resolves without a model download; the ONNX path can + * block on first-use download — a future revision can move it to a worker like + * EditModeController::selectByPart. + */ +class PartOpsController : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool hasSelection READ hasSelection NOTIFY selectionChanged) + +public: + static PartOpsController* instance(); + static PartOpsController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + /** True when exactly one mesh entity is selected (the split target). */ + bool hasSelection() const; + + /** Split the selected mesh into per-part submeshes (undoable). + * @param upAxis "x"|"y"|"z" (default "y") — forwarded to segmentation. + * @param category "auto"|"body"|"vegetation"|"vehicle"|"building". + * @param noModel force the offline geometric/rig-prior path. + * Emits splitFinished(status, isError). No-op (error) without a single + * selected mesh. */ + Q_INVOKABLE void splitSelectedIntoParts(const QString& upAxis = QStringLiteral("y"), + const QString& category = QStringLiteral("auto"), + bool noModel = false); + +signals: + void selectionChanged(); + void splitFinished(const QString& status, bool isError); + +private: + PartOpsController(); + static PartOpsController* m_pSingleton; +}; + +#endif // PARTOPSCONTROLLER_H diff --git a/src/PartOpsMesh.cpp b/src/PartOpsMesh.cpp new file mode 100644 index 000000000..0c201c90c --- /dev/null +++ b/src/PartOpsMesh.cpp @@ -0,0 +1,128 @@ +#include "PartOpsMesh.h" + +#include "EditableMesh.h" + +#include +#include +#include + +#include + +bool PartOpsMesh::readSubMeshes(Ogre::Entity* entity, + std::vector& outSubMeshes) +{ + outSubMeshes.clear(); + if (!entity || !entity->getMesh()) + return false; + EditableMesh em; + if (!em.loadFromEntity(entity)) + return false; + outSubMeshes = em.subMeshes(); + return !outSubMeshes.empty(); +} + +Ogre::MeshPtr PartOpsMesh::buildMesh(const std::vector& subMeshes, + const std::string& baseName, + const QString& skeletonName, + const std::vector& subMeshNames) +{ + if (subMeshes.empty()) + return Ogre::MeshPtr(); + // 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. + 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); + if (!mesh) + return mesh; + + // Register each part's name on the submesh (Mesh::nameSubMesh) so the Scene + // tree shows "head"/"torso"/… instead of a positional index, and the name + // round-trips through FBX export (FBXExporter reads getSubMeshNameMap) → + // reimport (MeshProcessor reads aiMesh::mName). NB createNewMesh skips empty + // editable submeshes, so guard on the built count. + for (unsigned short i = 0; + i < mesh->getNumSubMeshes() && i < static_cast(subMeshNames.size()); + ++i) { + if (!subMeshNames[i].isEmpty()) + mesh->nameSubMesh(subMeshNames[i].toStdString(), i); + } + + // createNewMesh only authors geometry; a SKINNED source needs its skeleton + // rebound and bone assignments recompiled onto the new mesh (mirrors + // EditableMesh::resizeEntityBuffers). Split preserves bone indices, and + // every part references the same source skeleton, so the assignments stay + // valid without renumbering. + if (!skeletonName.isEmpty()) { + // setSkeletonName loads the skeleton resource lazily; if it can't be + // resolved it throws — catch and return a geometry-only mesh rather + // than failing the whole split. + try { + mesh->setSkeletonName(skeletonName.toStdString()); + } catch (const Ogre::Exception&) { + return mesh; + } + if (mesh->hasSkeleton()) { + mesh->clearBoneAssignments(); + for (unsigned short i = 0; i < mesh->getNumSubMeshes() + && i < static_cast(subMeshes.size()); ++i) { + Ogre::SubMesh* sm = mesh->getSubMesh(i); + const EditableSubMesh& es = subMeshes[i]; + sm->clearBoneAssignments(); + for (size_t vi = 0; vi < es.vertices.size(); ++vi) { + for (const auto& ba : es.vertices[vi].boneAssignments) { + Ogre::VertexBoneAssignment vba; + vba.vertexIndex = static_cast(vi); + vba.boneIndex = ba.boneIndex; + vba.weight = ba.weight; + sm->addBoneAssignment(vba); + } + } + } + mesh->_compileBoneAssignments(); + } + } + return mesh; +} + +PartOpsMesh::SplitOutcome +PartOpsMesh::splitEntity(Ogre::Entity* entity, + const std::vector& faceLabels, + const std::vector& groups, + const SubMeshOps::SplitOptions& opts, + const std::string& baseName) +{ + SplitOutcome out; + std::vector src; + if (!readSubMeshes(entity, src)) { + out.error = QStringLiteral("could not read mesh geometry from entity"); + return out; + } + + SubMeshOps::SplitResult split = + SubMeshOps::splitByFaceGroups(src, faceLabels, groups, opts); + if (!split.ok) { + out.error = split.error; + return out; + } + + QString skelName; + if (entity->getMesh() && entity->getMesh()->hasSkeleton()) + skelName = QString::fromStdString(entity->getMesh()->getSkeletonName()); + Ogre::MeshPtr mesh = buildMesh(split.subMeshes, baseName, skelName, split.partNames); + if (!mesh) { + out.error = QStringLiteral("failed to build split mesh"); + return out; + } + + out.ok = true; + out.mesh = mesh; + out.partNames = std::move(split.partNames); + out.createdSubMeshes = split.createdSubMeshes; + out.duplicatedBoundaryVertices = split.duplicatedBoundaryVertices; + return out; +} diff --git a/src/PartOpsMesh.h b/src/PartOpsMesh.h new file mode 100644 index 000000000..a35b9870b --- /dev/null +++ b/src/PartOpsMesh.h @@ -0,0 +1,80 @@ +#ifndef PARTOPSMESH_H +#define PARTOPSMESH_H + +#include "SubMeshOps.h" + +#include + +#include + +#include +#include + +namespace Ogre { +class Entity; +} + +/** + * @brief Ogre adapter for the PartOps core (#859) — the buffer-touching layer. + * + * `SubMeshOps` is Ogre-buffer-free (operates on `EditableSubMesh` data) so it + * can be unit-tested headless. This adapter bridges it to live Ogre meshes: + * read an entity's geometry into `EditableSubMesh`es (full attributes + bone + * assignments), run a `SubMeshOps` operation, and build a fresh `Ogre::Mesh` + * from the result via the existing `EditableMesh::createNewMesh` path. + * + * The GLOBAL triangle order this adapter reads (submesh-then-local, shared + * vertex data first) is identical to `AutoRig::gatherGeometry`, so a + * `MeshSegmenter::Result::faceLabels` array produced from that geometry maps + * 1:1 onto the submeshes this reads — the split routes each labelled triangle + * to the right part with no re-derivation. + */ +class PartOpsMesh +{ +public: + /** Read an entity's mesh into attribute-complete `EditableSubMesh`es, one + * per Ogre submesh, in submesh order. Returns false on a null/empty + * entity. Uses `EditableMesh::loadFromEntity` under the hood so every + * supported attribute (normal/uv/colour/tangent/bone-assignments/n-gon + * faces/UV seams) is captured. */ + static bool readSubMeshes(Ogre::Entity* entity, + std::vector& outSubMeshes); + + /** Build a new detached `Ogre::Mesh` from `subMeshes` (one Ogre SubMesh + * each), named from `baseName`. Recomputes normals/bounds. Returns null + * on empty input. Reuses `EditableMesh::createNewMesh`. + * + * When `skeletonName` is non-empty, the new mesh is bound to that skeleton + * and each submesh's `EditableVertex::boneAssignments` are re-added and + * compiled (`Mesh::_compileBoneAssignments`), so a split of a SKINNED mesh + * keeps working weights — the #861 "skinned fixtures retain valid bone + * assignments" criterion. Bone indices are preserved as-is (the split does + * not renumber bones), which is valid because every part shares the source + * skeleton. */ + static Ogre::MeshPtr buildMesh(const std::vector& subMeshes, + const std::string& baseName, + const QString& skeletonName = QString(), + const std::vector& subMeshNames = {}); + + struct SplitOutcome { + bool ok = false; + QString error; + Ogre::MeshPtr mesh; ///< the split result as a fresh mesh. + std::vector partNames; ///< one per created submesh. + int createdSubMeshes = 0; + int duplicatedBoundaryVertices = 0; + }; + + /** Full headless split: read `entity`, run `SubMeshOps::splitByFaceGroups` + * with `faceLabels` (must match the entity's global triangle count) and + * `groups`, and build a new mesh. Does NOT touch the live entity — the + * caller exports the returned mesh (CLI) or swaps it onto the entity via + * an undo command (GUI). */ + static SplitOutcome splitEntity(Ogre::Entity* entity, + const std::vector& faceLabels, + const std::vector& groups, + const SubMeshOps::SplitOptions& opts, + const std::string& baseName); +}; + +#endif // PARTOPSMESH_H diff --git a/src/SceneTreeModel.cpp b/src/SceneTreeModel.cpp index 987f9bc45..4d49417a8 100644 --- a/src/SceneTreeModel.cpp +++ b/src/SceneTreeModel.cpp @@ -152,11 +152,24 @@ void SceneTreeModel::buildChildren(Ogre::SceneNode* sceneNode, SceneTreeItem* pa auto* entItem = new SceneTreeItem(entName, SceneTreeItem::Entity, entity, nodeItem); nodeItem->appendChild(entItem); - // Add sub-entities + // Add sub-entities. Prefer the mesh's registered submesh name + // (Mesh::nameSubMesh — e.g. PartOps "head"/"torso") over the bare + // positional index, so split part names show in the tree. + const Ogre::Mesh::SubMeshNameMap* nameMap = nullptr; + if (entity->getMesh()) + nameMap = &entity->getMesh()->getSubMeshNameMap(); for (unsigned int s = 0; s < entity->getNumSubEntities(); ++s) { Ogre::SubEntity* sub = entity->getSubEntity(s); QString subName = QString::number(s); + if (nameMap) { + for (const auto& kv : *nameMap) { + if (kv.second == s && !kv.first.empty()) { + subName = QString::fromStdString(kv.first); + break; + } + } + } auto* subItem = new SceneTreeItem(subName, SceneTreeItem::SubEntity, sub, entItem); entItem->appendChild(subItem); } diff --git a/src/SubMeshOps.cpp b/src/SubMeshOps.cpp new file mode 100644 index 000000000..cb61b7be3 --- /dev/null +++ b/src/SubMeshOps.cpp @@ -0,0 +1,596 @@ +#include "SubMeshOps.h" + +#include "MeshSegmenter.h" + +#include +#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) +{ + std::map byLabel; // ordered by label so Unknown(0) is first + for (uint32_t tri = 0; tri < faceLabels.size(); ++tri) { + const int label = faceLabels[tri]; + auto it = byLabel.find(label); + if (it == byLabel.end()) { + FaceGroup g; + g.label = label; + g.name = MeshSegmenter::partName(label); + it = byLabel.emplace(label, std::move(g)).first; + } + it->second.triangles.push_back(tri); + } + std::vector out; + out.reserve(byLabel.size()); + for (auto& kv : byLabel) + out.push_back(std::move(kv.second)); + return out; +} + +size_t SubMeshOps::totalTriangleCount(const std::vector& subMeshes) +{ + size_t n = 0; + for (const auto& sm : subMeshes) + n += sm.triangles.size(); + return n; +} + +namespace { + +// Resolve a GLOBAL triangle index to (submesh, localTriangle). The global +// stream is submesh-then-local order — the same convention MeshSegmenter's +// faceLabels use (built by facesFromVertexLabels over the concatenated index +// buffer) and EditModeController::globalTriToLocal. +bool globalTriToLocal(const std::vector& subMeshes, uint32_t globalTri, + size_t& subOut, size_t& localOut) +{ + size_t base = 0; + for (size_t s = 0; s < subMeshes.size(); ++s) { + const size_t n = subMeshes[s].triangles.size(); + if (globalTri < base + n) { + subOut = s; + localOut = globalTri - base; + return true; + } + base += n; + } + return false; +} + +} // namespace + +SubMeshOps::SplitResult +SubMeshOps::splitByFaceGroups(const std::vector& subMeshes, + const std::vector& faceLabels, + const std::vector& groups) +{ + return splitByFaceGroups(subMeshes, faceLabels, groups, SplitOptions{}); +} + +SubMeshOps::SplitResult +SubMeshOps::splitByFaceGroups(const std::vector& subMeshes, + const std::vector& faceLabels, + const std::vector& groups, + const SplitOptions& opts) +{ + SplitResult result; + + const size_t triTotal = totalTriangleCount(subMeshes); + if (faceLabels.size() != triTotal) { + result.error = QStringLiteral( + "face label count (%1) does not match triangle count (%2)") + .arg(faceLabels.size()).arg(triTotal); + return result; + } + + // Accepted groups only (skip excluded), label -> group meta. + std::unordered_map accepted; + for (const FaceGroup& g : groups) { + if (!g.excluded) + accepted.emplace(g.label, &g); + } + if (accepted.empty()) { + result.error = QStringLiteral("no part groups accepted (all excluded or empty)"); + return result; + } + + // One output submesh per (accepted label, source material). Keying on the + // material too is required for multi-material assets: a single part label + // (e.g. "torso") whose triangles come from two different source materials + // must emit TWO submeshes so each keeps its own material — collapsing them + // onto whichever triangle came first silently repaints the model (#859 + // review). When assignPartMaterials is set the part gets one generated + // material regardless, so it keys on the label alone. + struct Builder { + int label = 0; + QString name; + std::string sourceMaterial; // the key's material (empty when part-mat) + EditableSubMesh sub; + bool sourceHadFaces = false; + int firstGlobalTri = 0; // for deterministic output ordering + std::vector> remap; + }; + + // Builder key: label, plus source material unless we're assigning one + // material per part. Kept ordered for determinism. + using BuilderKey = std::pair; + std::map builderOfKey; + std::vector builders; + + int duplicated = 0; + // Track how many builders each (source submesh, source vertex) landed in, + // to count boundary duplications (a vertex used by >1 builder is dup'd). + std::vector> groupUseCount(subMeshes.size()); + + for (uint32_t gtri = 0; gtri < faceLabels.size(); ++gtri) { + const int label = faceLabels[gtri]; + auto ait = accepted.find(label); + if (ait == accepted.end()) + continue; // excluded / no accepted group -> dropped + + size_t s = 0, localTri = 0; + if (!globalTriToLocal(subMeshes, gtri, s, localTri)) + continue; + const EditableSubMesh& src = subMeshes[s]; + const EditableTriangle& tri = src.triangles[localTri]; + + // assignPartMaterials → one submesh per label (material is generated); + // else split per source material so multi-material parts round-trip. + const std::string keyMat = opts.assignPartMaterials ? std::string() : src.materialName; + const BuilderKey key{label, keyMat}; + auto kit = builderOfKey.find(key); + size_t bi; + if (kit == builderOfKey.end()) { + Builder nb; + nb.label = label; + nb.name = ait->second->name; + nb.sourceMaterial = keyMat; + nb.firstGlobalTri = static_cast(gtri); + nb.remap.resize(subMeshes.size()); + nb.sub.materialName = + opts.assignPartMaterials + ? (opts.namePrefix.isEmpty() + ? nb.name.toStdString() + : (opts.namePrefix + QStringLiteral(".") + nb.name).toStdString()) + : src.materialName; + bi = builders.size(); + builderOfKey.emplace(key, bi); + builders.push_back(std::move(nb)); + } else { + bi = kit->second; + } + + Builder& b = builders[bi]; + b.sourceHadFaces = b.sourceHadFaces || !src.faces.empty(); + + EditableTriangle newTri; + for (int k = 0; k < 3; ++k) { + const unsigned int srcV = tri.indices[k]; + auto& map = b.remap[s]; + auto vit = map.find(srcV); + unsigned int newV; + if (vit == map.end()) { + newV = static_cast(b.sub.vertices.size()); + b.sub.vertices.push_back(src.vertices[srcV]); + map.emplace(srcV, newV); + // Boundary bookkeeping: this source vertex is now used by one + // more builder. The 2nd+ builder to claim it is a duplication. + int& uses = groupUseCount[s][srcV]; + if (uses >= 1) + ++duplicated; + ++uses; + } else { + newV = vit->second; + } + newTri.indices[k] = newV; + } + b.sub.triangles.push_back(newTri); + } + + // Deterministic emit order: by part label, then by first-contributing + // triangle (so multi-material pieces of one part stay grouped + stable). + std::sort(builders.begin(), builders.end(), [](const Builder& a, const Builder& b) { + if (a.label != b.label) + return a.label < b.label; + return a.firstGlobalTri < b.firstGlobalTri; + }); + + // Emit submeshes; rebuild n-gon faces where the source had them (promote + // the triangle soup back to trivial faces — the exporter/edit layer expects + // `faces` canonical when non-empty). Optional connected-component split. + // + // A part label can now produce MORE than one submesh (multiple source + // materials and/or disconnected islands), so names get a per-label running + // suffix: `torso`, `torso.1`, `torso.2`, … The first piece of each label + // keeps the bare name. Builders are label-sorted, so the counter is simply + // reset when the label changes. + std::unordered_map nameCounterByLabel; + for (Builder& b : builders) { + if (b.sub.triangles.empty()) + continue; + + std::vector pieces; + if (opts.splitDisconnected) { + // Partition this builder's submesh into connected components. + const int vcount = static_cast(b.sub.vertices.size()); + std::vector flat; + flat.reserve(b.sub.triangles.size() * 3); + for (const auto& t : b.sub.triangles) { + flat.push_back(t.indices[0]); + flat.push_back(t.indices[1]); + flat.push_back(t.indices[2]); + } + std::vector island; + const int islands = MeshSegmenter::connectedComponents( + vcount, flat.data(), static_cast(flat.size()), island); + if (islands <= 1) { + pieces.push_back(std::move(b.sub)); + } else { + pieces.resize(islands); + std::vector> pmap(islands); + for (const auto& t : b.sub.triangles) { + const int isl = island[t.indices[0]]; + EditableSubMesh& piece = pieces[isl]; + if (piece.vertices.empty() && piece.triangles.empty()) + piece.materialName = b.sub.materialName; + EditableTriangle nt; + for (int k = 0; k < 3; ++k) { + const unsigned int sv = t.indices[k]; + auto& m = pmap[isl]; + auto it = m.find(sv); + unsigned int nv; + if (it == m.end()) { + nv = static_cast(piece.vertices.size()); + piece.vertices.push_back(b.sub.vertices[sv]); + m.emplace(sv, nv); + } else { + nv = it->second; + } + nt.indices[k] = nv; + } + piece.triangles.push_back(nt); + } + } + } else { + pieces.push_back(std::move(b.sub)); + } + + for (EditableSubMesh& piece : pieces) { + if (piece.triangles.empty()) + continue; + if (b.sourceHadFaces) + promoteTrianglesToFaces(piece); // keep n-gon storage canonical + const int n = nameCounterByLabel[b.label]++; + QString partName = b.name; + if (n > 0) + partName += QStringLiteral(".%1").arg(n); + result.subMeshes.push_back(std::move(piece)); + result.partNames.push_back(partName); + } + } + + if (result.subMeshes.empty()) { + result.error = QStringLiteral("split produced no geometry"); + return result; + } + + result.duplicatedBoundaryVertices = duplicated; + result.createdSubMeshes = static_cast(result.subMeshes.size()); + result.ok = true; + return result; +} + +SubMeshOps::JoinResult SubMeshOps::joinParts(const std::vector& parts) +{ + JoinResult result; + if (parts.empty()) { + result.error = QStringLiteral("no parts to join"); + return result; + } + + // Output submeshes keyed by material name so parts sharing a material merge + // into one submesh (matches the epic's "expected submeshes/materials"). + std::vector merged; + std::unordered_map byMaterial; + + for (const JoinPart& part : parts) { + // Linear part of the transform for normals/tangents (inverse-transpose + // for correctness under non-uniform scale; for the common rigid case + // this equals the rotation). + const Ogre::Matrix4& M = part.transform; + Ogre::Matrix3 linear; + M.extract3x3Matrix(linear); + Ogre::Matrix3 normalMat = linear.Inverse().Transpose(); + + for (const EditableSubMesh& src : part.subMeshes) { + auto mit = byMaterial.find(src.materialName); + size_t dstIdx; + if (mit == byMaterial.end()) { + dstIdx = merged.size(); + merged.emplace_back(); + merged.back().materialName = src.materialName; + byMaterial.emplace(src.materialName, dstIdx); + } else { + dstIdx = mit->second; + } + EditableSubMesh& dst = merged[dstIdx]; + const unsigned int base = static_cast(dst.vertices.size()); + + for (const EditableVertex& sv : src.vertices) { + EditableVertex v = sv; + v.position = M * sv.position; + if (v.hasNormal) + v.normal = (normalMat * sv.normal).normalisedCopy(); + if (v.hasTangent) { + Ogre::Vector3 t3(sv.tangent.x, sv.tangent.y, sv.tangent.z); + t3 = (linear * t3).normalisedCopy(); + v.tangent = Ogre::Vector4(t3.x, t3.y, t3.z, sv.tangent.w); + } + dst.vertices.push_back(v); + } + 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); + } + } + } + + // Rebuild trivial n-gon faces so the merged submeshes stay consistent with + // the editor's canonical-faces invariant when consumed downstream. + result.subMeshes = std::move(merged); + result.ok = true; + return result; +} + +std::vector +SubMeshOps::explodeOffsets(const std::vector& partCentroids, + const Ogre::AxisAlignedBox& assemblyBounds, float distance) +{ + std::vector offsets(partCentroids.size(), Ogre::Vector3::ZERO); + if (partCentroids.empty()) + return offsets; + + Ogre::Vector3 center = Ogre::Vector3::ZERO; + for (const auto& c : partCentroids) + center += c; + center /= static_cast(partCentroids.size()); + + float diag = 1.0f; + if (!assemblyBounds.isNull() && !assemblyBounds.isInfinite()) + diag = assemblyBounds.getSize().length(); + if (!(diag > 0.0f)) + diag = 1.0f; + + for (size_t i = 0; i < partCentroids.size(); ++i) { + Ogre::Vector3 dir = partCentroids[i] - center; + const float len = dir.length(); + if (len > 1e-6f) + offsets[i] = (dir / len) * (distance * diag); + } + return offsets; +} + +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; + 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(); + } + 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; + } + 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; +} diff --git a/src/SubMeshOps.h b/src/SubMeshOps.h new file mode 100644 index 000000000..65e90da1f --- /dev/null +++ b/src/SubMeshOps.h @@ -0,0 +1,202 @@ +#ifndef SUBMESHOPS_H +#define SUBMESHOPS_H + +#include "EditableMesh.h" + +#include + +#include +#include +#include + +/** + * @brief Pure-data mesh-authoring core for the PartOps epic (#859). + * + * 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. + * + * Everything here operates on `std::vector` — the same + * attribute-complete editable representation `EditableMesh` loads from an + * Ogre entity (position/normal/uv/colour/tangent/bone-assignments/n-gon + * faces/UV seams). That makes the geometry math **Ogre-buffer-free and + * unit-testable**, and lets the Ogre adapter reuse the existing + * `EditableMesh::createNewMesh` / `buildSubMeshBuffers` path to realise the + * result — attribute + bone-weight preservation comes for free because + * splitting merely copies `EditableVertex` values into per-group submeshes. + * + * The GUI (`EditModeController`/`PartOpsController`), CLI (`qtmesh segment + * --split-parts` etc.), and MCP (`split_mesh_by_segments`, …) surfaces are + * thin adapters over this core. + */ +class SubMeshOps +{ +public: + // ------------------------------------------------------------------------- + // Segmentation grouping (Slice A #860) + // ------------------------------------------------------------------------- + + /** One detected part: a segmentation label plus the faces assigned to it, + * addressed as GLOBAL triangle indices (the flat triangle stream across + * every submesh, in submesh-then-local order — matching + * `MeshSegmenter::Result::faceLabels`). */ + struct FaceGroup { + int label = 0; ///< MeshSegmenter::Part value. + QString name; ///< display / submesh-suffix name (e.g. "head"). + std::vector triangles; ///< global triangle indices in this group. + bool excluded = false; ///< user excluded it from split/explode. + }; + + /** Group a flat per-face label array (one entry per GLOBAL triangle) into + * `FaceGroup`s, one per distinct label that occurs. Names come from + * `MeshSegmenter::partName`. Order is stable: groups sorted by label + * value, so `unknown`(0) is first. Empty labels → empty result. */ + static std::vector groupFacesByLabel(const std::vector& faceLabels); + + /** Total triangle count across every submesh (the length a valid + * `faceLabels` array must have). */ + static size_t totalTriangleCount(const std::vector& subMeshes); + + // ------------------------------------------------------------------------- + // Split (Slice B #861) + // ------------------------------------------------------------------------- + + struct SplitOptions { + /** Prefix for generated submesh names: `.` (e.g. + * "Body" → "Body.head"). Empty → just the group name. */ + QString namePrefix = QStringLiteral("Body"); + /** When true, split every group's faces further into connected + * components so e.g. two disjoint islands sharing a label become two + * submeshes (`head`, `head.1`). Off by default: one submesh per label. */ + bool splitDisconnected = false; + /** Assign a distinct generated material name per part instead of + * preserving the source material. The Ogre adapter creates the + * materials; the core only records the intended name. */ + bool assignPartMaterials = false; + }; + + struct SplitResult { + bool ok = false; + QString error; + /** The new submesh layout (replaces the input entirely). */ + std::vector subMeshes; + /** Parallel to `subMeshes`: the part name each came from. */ + std::vector partNames; + /** Boundary vertices duplicated so parts are independent (diagnostic). */ + int duplicatedBoundaryVertices = 0; + int createdSubMeshes = 0; + }; + + /** Split `subMeshes` into one new submesh per accepted `FaceGroup`. + * + * `faceLabels` must have `totalTriangleCount(subMeshes)` entries; each + * global triangle is routed to the group whose `label` matches, EXCEPT + * faces whose group is `excluded` (those are dropped from the output — + * the caller decides whether that's intended). A face whose label has no + * accepted group is also dropped. + * + * Every vertex referenced by a group's triangles is copied into that + * group's submesh with a fresh local index; a vertex shared by two groups + * is therefore DUPLICATED (counted in `duplicatedBoundaryVertices`), so + * the resulting submeshes are geometrically independent. All + * `EditableVertex` attributes (normal/uv/colour/tangent/bone-assignments) + * and the source material carry over unchanged; n-gon `faces` are rebuilt + * when the source submesh had them, else triangle-only. + * + * Deterministic; never throws. Returns `ok=false` with `error` set on a + * size mismatch or when no group survives. */ + static SplitResult splitByFaceGroups(const std::vector& subMeshes, + const std::vector& faceLabels, + const std::vector& groups, + const SplitOptions& opts); + /** Overload with default options. Separate (not a `= {}` default argument) + * because `SplitOptions` has a `QString` NSDMI, which a defaulted + * argument would force the compiler to evaluate at this class-definition + * scope where the enclosing type is still incomplete (GCC hard error). */ + static SplitResult splitByFaceGroups(const std::vector& subMeshes, + const std::vector& faceLabels, + const std::vector& groups); + + // ------------------------------------------------------------------------- + // Join (Slice C #862) + // ------------------------------------------------------------------------- + + /** One part to join: its submeshes plus a world transform (rows of a 4x4, + * applied to positions; the inverse-transpose to normals/tangents). The + * Ogre adapter fills `transform` from each exploded node's world matrix. */ + struct JoinPart { + std::vector subMeshes; + Ogre::Matrix4 transform = Ogre::Matrix4::IDENTITY; + }; + + struct JoinResult { + bool ok = false; + QString error; + std::vector subMeshes; ///< merged layout. + }; + + /** Merge `parts` into one mesh, baking each part's `transform` into its + * vertex positions (and rotating normals/tangents by the transform's + * linear part). Submeshes that share a material name are concatenated + * into one output submesh; distinct materials stay separate. Bone + * assignments are preserved as-is (join does not attempt skeleton + * reconciliation — documented limitation). Deterministic. */ + static JoinResult joinParts(const std::vector& parts); + + // ------------------------------------------------------------------------- + // Explode offsets (Slice C #862) + // ------------------------------------------------------------------------- + + /** Compute an outward explode offset per part: the direction from the + * whole-assembly centroid to each part's centroid, scaled by `distance` + * times the assembly bounding-box diagonal. Degenerate (part centroid == + * assembly centroid) → zero offset. `partCentroids` in, offsets out + * (parallel). Pure math, unit-testable. */ + static std::vector explodeOffsets(const std::vector& partCentroids, + 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); +}; + +#endif // SUBMESHOPS_H diff --git a/src/SubMeshOps_test.cpp b/src/SubMeshOps_test.cpp new file mode 100644 index 000000000..0a68535d5 --- /dev/null +++ b/src/SubMeshOps_test.cpp @@ -0,0 +1,339 @@ +// Pure-data tests for the PartOps core (#859). No Ogre buffers / GL — these +// exercise SubMeshOps entirely on in-memory EditableSubMesh data, so they run +// headless under Xvfb like the rest of the CI suite. + +#include + +#include "SubMeshOps.h" +#include "MeshSegmenter.h" + +#include + +namespace { + +EditableVertex vtx(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; +} + +void addTri(EditableSubMesh& sm, unsigned a, unsigned b, unsigned c) +{ + EditableTriangle t; + t.indices[0] = a; t.indices[1] = b; t.indices[2] = c; + sm.triangles.push_back(t); +} + +// A single submesh with 4 verts / 2 tris forming a quad in the XZ plane, plus +// a second disjoint quad — used to exercise label grouping and splitting. +// Layout: tris 0,1 = "quad A" (label 1), tris 2,3 = "quad B" (label 2). +EditableSubMesh twoQuadSubmesh() +{ + EditableSubMesh sm; + sm.materialName = "Mat"; + // quad A + sm.vertices.push_back(vtx(0, 0, 0)); // 0 + sm.vertices.push_back(vtx(1, 0, 0)); // 1 + sm.vertices.push_back(vtx(1, 0, 1)); // 2 + sm.vertices.push_back(vtx(0, 0, 1)); // 3 + // quad B (shares edge 1-2 with A → verts 1,2 are the boundary) + sm.vertices.push_back(vtx(2, 0, 0)); // 4 + sm.vertices.push_back(vtx(2, 0, 1)); // 5 + addTri(sm, 0, 1, 2); // tri 0 (A) + addTri(sm, 0, 2, 3); // tri 1 (A) + addTri(sm, 1, 4, 5); // tri 2 (B) + addTri(sm, 1, 5, 2); // tri 3 (B) shares verts 1,2 with A + return sm; +} + +} // namespace + +TEST(SubMeshOpsTest, GroupFacesByLabelStableOrder) +{ + // labels: two tris of head(1), one torso(2), one unknown(0) + std::vector labels = {1, 1, 2, 0}; + auto groups = SubMeshOps::groupFacesByLabel(labels); + ASSERT_EQ(groups.size(), 3u); + // Sorted by label → unknown(0), head(1), torso(2). + EXPECT_EQ(groups[0].label, 0); + EXPECT_EQ(groups[0].name, MeshSegmenter::partName(0)); + EXPECT_EQ(groups[1].label, 1); + EXPECT_EQ(groups[1].triangles.size(), 2u); + EXPECT_EQ(groups[1].triangles[0], 0u); + EXPECT_EQ(groups[1].triangles[1], 1u); + EXPECT_EQ(groups[2].label, 2); + EXPECT_EQ(groups[2].triangles.size(), 1u); + EXPECT_EQ(groups[2].triangles[0], 2u); +} + +TEST(SubMeshOpsTest, SplitByFaceGroupsCreatesOneSubmeshPerLabel) +{ + std::vector in = {twoQuadSubmesh()}; + // tris 0,1 → label 1 (head); tris 2,3 → label 2 (torso) + std::vector faceLabels = {1, 1, 2, 2}; + auto groups = SubMeshOps::groupFacesByLabel(faceLabels); + + SubMeshOps::SplitResult r = SubMeshOps::splitByFaceGroups(in, faceLabels, groups); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + ASSERT_EQ(r.subMeshes.size(), 2u); + EXPECT_EQ(r.createdSubMeshes, 2); + + // Part A: 2 tris, 4 unique verts. Part B: 2 tris, 4 unique verts. + EXPECT_EQ(r.subMeshes[0].triangles.size(), 2u); + EXPECT_EQ(r.subMeshes[1].triangles.size(), 2u); + EXPECT_EQ(r.subMeshes[0].vertices.size(), 4u); + EXPECT_EQ(r.subMeshes[1].vertices.size(), 4u); + + // The two quads share verts 1 and 2 → both got duplicated into part B. + EXPECT_EQ(r.duplicatedBoundaryVertices, 2); + + // Material preserved by default. + EXPECT_EQ(r.subMeshes[0].materialName, "Mat"); + EXPECT_EQ(r.subMeshes[1].materialName, "Mat"); + + // Part names come from the labels. + EXPECT_EQ(r.partNames[0], MeshSegmenter::partName(1)); + EXPECT_EQ(r.partNames[1], MeshSegmenter::partName(2)); + + // Total triangles preserved (nothing dropped when all labels accepted). + EXPECT_EQ(SubMeshOps::totalTriangleCount(r.subMeshes), 4u); +} + +TEST(SubMeshOpsTest, SplitKeepsSourceMaterialsWhenLabelSpansMaterials) +{ + // #859 review (Codex P2): a single part label whose triangles come from + // TWO source materials must emit two submeshes — one per material — not + // collapse onto whichever triangle came first. Two source submeshes with + // different materials; every triangle labelled the same part (1). + EditableSubMesh matA; + matA.materialName = "MatA"; + matA.vertices = {vtx(0, 0, 0), vtx(1, 0, 0), vtx(0, 0, 1)}; + addTri(matA, 0, 1, 2); + EditableSubMesh matB; + matB.materialName = "MatB"; + matB.vertices = {vtx(2, 0, 0), vtx(3, 0, 0), vtx(2, 0, 1)}; + addTri(matB, 0, 1, 2); + std::vector in = {matA, matB}; + std::vector faceLabels = {1, 1}; // both tris → same part label + + auto groups = SubMeshOps::groupFacesByLabel(faceLabels); + SubMeshOps::SplitResult r = SubMeshOps::splitByFaceGroups(in, faceLabels, groups); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + // Two output submeshes: one per source material, both from part "head"(1). + ASSERT_EQ(r.subMeshes.size(), 2u); + std::set mats = {r.subMeshes[0].materialName, r.subMeshes[1].materialName}; + EXPECT_TRUE(mats.count("MatA")); + EXPECT_TRUE(mats.count("MatB")); + // Both name variants derive from the same part; the second gets a suffix. + const QString base = MeshSegmenter::partName(1); + EXPECT_EQ(r.partNames[0], base); + EXPECT_EQ(r.partNames[1], base + QStringLiteral(".1")); +} + +TEST(SubMeshOpsTest, SplitAssignPartMaterialsCollapsesAcrossSourceMaterials) +{ + // With assignPartMaterials the part gets ONE generated material, so a label + // spanning source materials becomes a single submesh (the material split is + // intentionally suppressed). + EditableSubMesh matA; matA.materialName = "MatA"; + matA.vertices = {vtx(0,0,0), vtx(1,0,0), vtx(0,0,1)}; addTri(matA,0,1,2); + EditableSubMesh matB; matB.materialName = "MatB"; + matB.vertices = {vtx(2,0,0), vtx(3,0,0), vtx(2,0,1)}; addTri(matB,0,1,2); + std::vector in = {matA, matB}; + std::vector faceLabels = {1, 1}; + auto groups = SubMeshOps::groupFacesByLabel(faceLabels); + SubMeshOps::SplitOptions opts; + opts.assignPartMaterials = true; + opts.namePrefix = QStringLiteral("Body"); + SubMeshOps::SplitResult r = SubMeshOps::splitByFaceGroups(in, faceLabels, groups, opts); + ASSERT_TRUE(r.ok); + ASSERT_EQ(r.subMeshes.size(), 1u); + EXPECT_EQ(r.subMeshes[0].materialName, + (QStringLiteral("Body.") + MeshSegmenter::partName(1)).toStdString()); +} + +TEST(SubMeshOpsTest, SplitExcludesGroupAndDropsItsFaces) +{ + std::vector in = {twoQuadSubmesh()}; + std::vector faceLabels = {1, 1, 2, 2}; + auto groups = SubMeshOps::groupFacesByLabel(faceLabels); + // Exclude torso (label 2). + for (auto& g : groups) + if (g.label == 2) g.excluded = true; + + SubMeshOps::SplitResult r = SubMeshOps::splitByFaceGroups(in, faceLabels, groups); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + ASSERT_EQ(r.subMeshes.size(), 1u); + EXPECT_EQ(r.partNames[0], MeshSegmenter::partName(1)); + EXPECT_EQ(r.subMeshes[0].triangles.size(), 2u); + // Excluded faces are dropped, not merged elsewhere. + EXPECT_EQ(SubMeshOps::totalTriangleCount(r.subMeshes), 2u); +} + +TEST(SubMeshOpsTest, SplitRejectsMismatchedLabelCount) +{ + std::vector in = {twoQuadSubmesh()}; + std::vector faceLabels = {1, 1, 2}; // 3 labels, mesh has 4 tris + auto groups = SubMeshOps::groupFacesByLabel(faceLabels); + SubMeshOps::SplitResult r = SubMeshOps::splitByFaceGroups(in, faceLabels, groups); + EXPECT_FALSE(r.ok); + EXPECT_FALSE(r.error.isEmpty()); +} + +TEST(SubMeshOpsTest, SplitPreservesVertexAttributesAndBoneWeights) +{ + EditableSubMesh sm; + sm.materialName = "Skin"; + for (int i = 0; i < 3; ++i) { + EditableVertex v = vtx(float(i), 0, 0); + v.hasUV = true; + v.uv = Ogre::Vector2(0.25f * i, 0.5f); + v.hasColor = true; + v.color = Ogre::ColourValue(0.1f, 0.2f, 0.3f, 1.0f); + EditableBoneAssignment ba; + ba.boneIndex = static_cast(i); + ba.weight = 1.0f; + v.boneAssignments.push_back(ba); + sm.vertices.push_back(v); + } + addTri(sm, 0, 1, 2); + std::vector in = {sm}; + std::vector faceLabels = {1}; + auto groups = SubMeshOps::groupFacesByLabel(faceLabels); + + SubMeshOps::SplitResult r = SubMeshOps::splitByFaceGroups(in, faceLabels, groups); + ASSERT_TRUE(r.ok); + ASSERT_EQ(r.subMeshes.size(), 1u); + const auto& out = r.subMeshes[0]; + ASSERT_EQ(out.vertices.size(), 3u); + for (size_t i = 0; i < 3; ++i) { + EXPECT_TRUE(out.vertices[i].hasUV); + EXPECT_TRUE(out.vertices[i].hasColor); + ASSERT_EQ(out.vertices[i].boneAssignments.size(), 1u); + EXPECT_EQ(out.vertices[i].boneAssignments[0].boneIndex, i); + EXPECT_FLOAT_EQ(out.vertices[i].boneAssignments[0].weight, 1.0f); + } +} + +TEST(SubMeshOpsTest, JoinBakesTransformIntoPositions) +{ + // Two single-tri parts, same material. Part B translated +10 in X. + EditableSubMesh a; + a.materialName = "Mat"; + a.vertices = {vtx(0, 0, 0), vtx(1, 0, 0), vtx(0, 0, 1)}; + addTri(a, 0, 1, 2); + EditableSubMesh b = a; + + SubMeshOps::JoinPart pa{{a}, Ogre::Matrix4::IDENTITY}; + SubMeshOps::JoinPart pb; + pb.subMeshes = {b}; + pb.transform = Ogre::Matrix4::IDENTITY; + pb.transform.setTrans(Ogre::Vector3(10, 0, 0)); + + auto r = SubMeshOps::joinParts({pa, pb}); + ASSERT_TRUE(r.ok) << r.error.toStdString(); + // Same material → one merged submesh with 6 verts / 2 tris. + ASSERT_EQ(r.subMeshes.size(), 1u); + EXPECT_EQ(r.subMeshes[0].vertices.size(), 6u); + EXPECT_EQ(r.subMeshes[0].triangles.size(), 2u); + // Part B's first vertex baked to x=10. + EXPECT_FLOAT_EQ(r.subMeshes[0].vertices[3].position.x, 10.0f); + // Indices offset correctly (second tri references 3,4,5). + EXPECT_EQ(r.subMeshes[0].triangles[1].indices[0], 3u); +} + +TEST(SubMeshOpsTest, JoinKeepsDistinctMaterialsSeparate) +{ + EditableSubMesh a; a.materialName = "A"; a.vertices = {vtx(0,0,0), vtx(1,0,0), vtx(0,0,1)}; addTri(a,0,1,2); + EditableSubMesh b; b.materialName = "B"; b.vertices = {vtx(0,0,0), vtx(1,0,0), vtx(0,0,1)}; addTri(b,0,1,2); + SubMeshOps::JoinPart pa{{a}, Ogre::Matrix4::IDENTITY}; + SubMeshOps::JoinPart pb{{b}, Ogre::Matrix4::IDENTITY}; + auto r = SubMeshOps::joinParts({pa, pb}); + ASSERT_TRUE(r.ok); + EXPECT_EQ(r.subMeshes.size(), 2u); +} + +TEST(SubMeshOpsTest, ExplodeOffsetsPushOutwardFromCenter) +{ + std::vector centroids = { + Ogre::Vector3(-1, 0, 0), Ogre::Vector3(1, 0, 0)}; + Ogre::AxisAlignedBox bounds(Ogre::Vector3(-1, 0, 0), Ogre::Vector3(1, 0, 0)); + auto offs = SubMeshOps::explodeOffsets(centroids, bounds, 0.5f); + ASSERT_EQ(offs.size(), 2u); + // Opposite directions along X, equal magnitude. + EXPECT_LT(offs[0].x, 0.0f); + EXPECT_GT(offs[1].x, 0.0f); + EXPECT_NEAR(offs[0].x, -offs[1].x, 1e-5f); + // Magnitude = distance * diag (diag = 2 here) = 0.5*2 = 1. + 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()); +} diff --git a/src/TestHelpers.h b/src/TestHelpers.h index 52588d87b..227599be3 100644 --- a/src/TestHelpers.h +++ b/src/TestHelpers.h @@ -116,6 +116,35 @@ static inline QString testRobotMeshPath() return {}; } +/** + * Resolve a repo-relative asset path (e.g. "media/models/Rumba Dancing.fbx") + * against the same locations testRobotMeshPath() searches: two levels up from + * the test binary, the configured QTMESH_UT_SOURCE_ROOT, then the CWD-relative + * legacy path. Returns an absolute path if found, else empty. Use for optional + * fixtures (skip gracefully when absent). + */ +static inline QString testAssetPath(const QString& relative) +{ + const QString binDir = QCoreApplication::applicationDirPath(); + QDir dir(binDir); + if (dir.cdUp() && dir.cdUp()) { + const QString p = dir.absoluteFilePath(relative); + if (QFile::exists(p)) + return p; + } +#ifdef QTMESH_UT_SOURCE_ROOT + { + const QString p = QDir(QString::fromUtf8(QTMESH_UT_SOURCE_ROOT)).filePath(relative); + if (QFile::exists(p)) + return p; + } +#endif + const QString legacy = QStringLiteral("./") + relative; + if (QFile::exists(legacy)) + return QFileInfo(legacy).absoluteFilePath(); + return {}; +} + /** * Creates a hidden 1x1 QWidget and uses its native window handle to * create an Ogre RenderWindow named "TestHidden". This provides the diff --git a/src/commands/SplitMeshCommand.cpp b/src/commands/SplitMeshCommand.cpp new file mode 100644 index 000000000..0bdce14ac --- /dev/null +++ b/src/commands/SplitMeshCommand.cpp @@ -0,0 +1,170 @@ +#include "SplitMeshCommand.h" + +#include "Manager.h" +#include "MeshSegmenter.h" +#include "SubMeshOps.h" +#include "PartOpsMesh.h" +#include "AutoRig.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include +#include +#include + +#include + +SplitMeshCommand::SplitMeshCommand(std::string entityName, int upAxis, QString category, + bool noModel, QString namePrefix, QUndoCommand* parent) + : QUndoCommand(parent) + , mEntityName(std::move(entityName)) + , mUpAxis(upAxis) + , mCategory(std::move(category)) + , mNoModel(noModel) + , mNamePrefix(std::move(namePrefix)) +{ + setText(QStringLiteral("Split Mesh into Parts")); +} + +Ogre::Entity* SplitMeshCommand::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* SplitMeshCommand::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; + + // CRITICAL: drop EVERY selection reference before freeing the entity. + // SelectionSet tracks the selected entity AND its sub-entities (and the + // transform gizmos + Scene tree mirror that). It only auto-cleans on + // Manager::sceneNodeDestroyed, but we destroy the ENTITY while keeping the + // NODE. A targeted removeOne is NOT enough: getResolvedEntities() resolves + // through the SUB-entity list (sub->getParent()), and destroyEntity frees + // the sub-entities too — so any lingering sub-entity ref makes the next + // selection query dereference freed memory (the offline/GUI split crash). + // clearList() is the SelectionSet API built for exactly this — "use this + // one [when] items in the list have been destroyed" — it drops all + // references without touching the objects. Reselect the node afterward. + if (auto* sel = SelectionSet::getSingleton()) { + mReselectNode = sel->contains(node) ? node : nullptr; + sel->clearList(); + } + + // Destroy the current entity FIRST — Manager::createEntity names the new + // entity after the node, so the old one must release that name. + node->detachObject(cur); + mgr->getSceneMgr()->destroyEntity(cur); + + // createEntity re-attaches to the node, emits entityCreated (so the Scene + // tree rebuilds against the NEW entity), and re-applies light linking. + Ogre::Entity* ne = mgr->createEntity(node, mesh); + + // Restore selection to the node so the user keeps their target selected + // (createEntity already selects it unless we were mid-scene-init). + if (ne && mReselectNode) { + if (auto* sel = SelectionSet::getSingleton()) + sel->selectOne(node); + } + return ne; +} + +void SplitMeshCommand::redo() +{ + // Build the split mesh once; later redos just re-swap the cached result. + if (!mBuilt) { + mBuilt = true; + Ogre::Entity* entity = resolveEntity(); + if (!entity || !entity->getMesh()) { + mError = QStringLiteral("no entity to split"); + return; + } + mOriginalMesh = entity->getMesh(); // stays resident for undo. + + // Segment: gather geometry + rig-prior labels, resolve category, run + // predict — the same pipeline the CLI uses (offline when noModel). + std::vector verts; + std::vector indices; + if (!AutoRig::gatherGeometry(entity, verts, indices) || verts.empty()) { + mError = QStringLiteral("no readable geometry"); + return; + } + const int vertexCount = static_cast(verts.size() / 3); + int rigResolved = 0; + std::vector rigLabels = + AutoRig::rigPriorPartLabels(entity, vertexCount, &rigResolved); + + MeshSegmenter::Options opts; + opts.upAxis = mUpAxis; + opts.forceFallback = mNoModel; + bool ok = false; + opts.category = MeshSegmenter::categoryFromName(mCategory, &ok); + if (!ok) + opts.category = MeshSegmenter::Category::Auto; + if (!mNoModel) + opts.category = MeshSegmenter::resolveCategoryBlocking(verts.data(), vertexCount, opts); + else if (opts.category == MeshSegmenter::Category::Auto) + opts.category = MeshSegmenter::Category::Body; + + QString modelPath; + if (!mNoModel) + modelPath = MeshSegmenter::ensureModelBlocking(opts.category); + + const MeshSegmenter::Result r = MeshSegmenter::predict( + verts.data(), vertexCount, indices.data(), static_cast(indices.size()), + modelPath, opts, rigLabels.empty() ? nullptr : rigLabels.data()); + if (!r.ok) { + mError = r.error.isEmpty() ? QStringLiteral("segmentation failed") : r.error; + return; + } + + SubMeshOps::SplitOptions sopts; + if (!mNamePrefix.isEmpty()) + sopts.namePrefix = mNamePrefix; + auto groups = SubMeshOps::groupFacesByLabel(r.faceLabels); + PartOpsMesh::SplitOutcome so = PartOpsMesh::splitEntity( + entity, r.faceLabels, groups, sopts, + mEntityName + std::string("_parts")); + if (!so.ok) { + mError = so.error.isEmpty() ? QStringLiteral("split failed") : so.error; + return; + } + mSplitMesh = so.mesh; + mCreatedSubMeshes = so.createdSubMeshes; + mPartNames = so.partNames; + } + + if (!mSplitMesh) { + mOk = false; + return; // build failed on first redo; mError already set. + } + Ogre::Entity* ne = swapEntityMesh(mSplitMesh); + mOk = (ne != nullptr); + if (mOk) + SentryReporter::addBreadcrumb(QStringLiteral("mesh.parts.split_segments"), + QStringLiteral("parts=%1").arg(mCreatedSubMeshes)); + else if (mError.isEmpty()) + mError = QStringLiteral("failed to swap in split mesh"); +} + +void SplitMeshCommand::undo() +{ + if (!mOriginalMesh) + return; + swapEntityMesh(mOriginalMesh); +} diff --git a/src/commands/SplitMeshCommand.h b/src/commands/SplitMeshCommand.h new file mode 100644 index 000000000..22f67d55a --- /dev/null +++ b/src/commands/SplitMeshCommand.h @@ -0,0 +1,79 @@ +#ifndef SPLIT_MESH_COMMAND_H +#define SPLIT_MESH_COMMAND_H + +#include +#include + +#include + +#include +#include + +namespace Ogre { class Entity; class SceneNode; } + +/** + * Undoable PartOps split (#859/#861): replaces a fused entity's mesh with a + * split mesh whose submeshes are the detected parts (head/torso/…). + * + * A split changes the submesh COUNT, which the in-place edit-mode path + * (`EditMeshTopologyCommand` → `resizeEntityBuffers`) forbids — it only ever + * rewrites existing SubMesh buffers, never adds/removes SubMeshes. So this + * command swaps the whole mesh instead: it recreates the entity on the same + * scene node with a new `Ogre::MeshPtr`. + * + * redo(): first call runs segmentation + `PartOpsMesh::splitEntity` to build + * the split mesh (cached on the command so later redos are instant), then + * destroys the current entity and creates a new one bound to the split mesh on + * the same node. undo(): recreates the entity on the node bound to the ORIGINAL + * mesh (kept resident in MeshManager for the command's lifetime). + * + * Node and entity share a name (Manager::createEntity), so the command targets + * by that name and survives scene rebuilds like the other entity-scoped + * commands. Runs in Object mode (Edit Mode must be exited first — the caller + * guarantees this). + */ +class SplitMeshCommand : public QUndoCommand +{ +public: + /** @param entityName the fused entity to split (== its node name). + * @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). */ + SplitMeshCommand(std::string entityName, + int upAxis, + QString category, + bool noModel, + QString namePrefix, + QUndoCommand* parent = nullptr); + + void undo() override; + void redo() override; + + bool ok() const { return mOk; } + const QString& error() const { return mError; } + int createdSubMeshes() const { return mCreatedSubMeshes; } + const std::vector& partNames() const { return mPartNames; } + +private: + Ogre::Entity* resolveEntity() const; + // Swap the node's entity to `mesh`; returns the new entity (or null). + Ogre::Entity* swapEntityMesh(const Ogre::MeshPtr& mesh); + + std::string mEntityName; + int mUpAxis = 1; + QString mCategory; + bool mNoModel = false; + QString mNamePrefix; + + Ogre::SceneNode* mReselectNode = nullptr; ///< transient: node to reselect after a swap. + Ogre::MeshPtr mOriginalMesh; ///< kept resident so undo can restore it. + Ogre::MeshPtr mSplitMesh; ///< built once on first redo. + bool mBuilt = false; + bool mOk = false; + QString mError; + int mCreatedSubMeshes = 0; + std::vector mPartNames; +}; + +#endif // SPLIT_MESH_COMMAND_H diff --git a/src/commands/SplitMeshCommand_test.cpp b/src/commands/SplitMeshCommand_test.cpp new file mode 100644 index 000000000..f5e48d07b --- /dev/null +++ b/src/commands/SplitMeshCommand_test.cpp @@ -0,0 +1,53 @@ +#include + +#include + +#include "commands/SplitMeshCommand.h" + +// No-Ogre / error-branch coverage for SplitMeshCommand: the ctor/text contract, +// accessor state before redo(), redo() against an unresolvable entity name +// (→ ok()==false with an error), and undo() before any successful redo (strict +// no-op — no original mesh captured). resolveEntity() returns nullptr when +// Manager::getSingletonPtr() is null OR no entity matches, so a bogus name +// reliably drives the error branch without a scene. The full segment→split→swap +// round-trip needs a real mesh and is covered by the Ogre-gated CLI split test +// (CLIPipeline_cmdsplitparts_coverage_test.cpp). + +namespace { +const std::string kBogusEntity = "__qtmesh_nonexistent_entity_for_split_test__"; +} + +TEST(SplitMeshCommandTest, CtorSetsText) +{ + SplitMeshCommand cmd(kBogusEntity, 1, QStringLiteral("auto"), false, + QStringLiteral("Body")); + EXPECT_EQ(cmd.text(), QStringLiteral("Split Mesh into Parts")); +} + +TEST(SplitMeshCommandTest, InitialAccessorState) +{ + SplitMeshCommand cmd(kBogusEntity, 1, QStringLiteral("auto"), true, + QStringLiteral("Body")); + EXPECT_FALSE(cmd.ok()); + EXPECT_EQ(cmd.createdSubMeshes(), 0); + EXPECT_TRUE(cmd.partNames().empty()); +} + +TEST(SplitMeshCommandTest, RedoOnUnresolvableEntityFailsCleanly) +{ + SplitMeshCommand cmd(kBogusEntity, 1, QStringLiteral("auto"), true, + QStringLiteral("Body")); + cmd.redo(); // no scene / no entity → error branch + EXPECT_FALSE(cmd.ok()); + EXPECT_FALSE(cmd.error().isEmpty()); + EXPECT_EQ(cmd.createdSubMeshes(), 0); +} + +TEST(SplitMeshCommandTest, UndoBeforeRedoIsNoOp) +{ + SplitMeshCommand cmd(kBogusEntity, 1, QStringLiteral("auto"), false, + QStringLiteral("Body")); + // No original mesh captured yet → undo must not crash or mutate anything. + EXPECT_NO_THROW(cmd.undo()); + EXPECT_FALSE(cmd.ok()); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3f2fa4782..cc3c8f63d 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -113,6 +113,7 @@ #include "PropertiesPanelController.h" #include "MeshLodController.h" #include "MeshDecimatorController.h" +#include "PartOpsController.h" #include "MeshValidator.h" #include "AssetScanController.h" #include "UvUnwrapController.h" @@ -756,6 +757,11 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return UvUnwrapController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "PartOpsController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return PartOpsController::qmlInstance(engine, nullptr); + }); qmlRegisterSingletonType( "PropertiesPanel", 1, 0, "UVEditorController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { diff --git a/src/qml_resources.qrc b/src/qml_resources.qrc index 46ca92d4f..222768462 100644 --- a/src/qml_resources.qrc +++ b/src/qml_resources.qrc @@ -38,6 +38,8 @@ ../qml/SceneTreeNode.qml ../qml/ProfileGraph.qml ../qml/ThemedComboBox.qml + ../qml/ThemedCheckBox.qml + ../qml/ThemedButton.qml ../qml/TextureEditorWindow.qml diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d147d0c16..0ca6c15ac 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -186,6 +186,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/AlembicImporter.cpp ${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/SkeletonBoneCommands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/UVEditCommand.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/commands/UvSeamCommands.cpp @@ -212,6 +213,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshDecimator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshDecimatorController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MeshLodController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PartOpsController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/UvUnwrapController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/UVEditorController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/UVTransform.cpp @@ -257,6 +259,8 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/EditorModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/HalfEdgeMesh.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/SubMeshOps.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/PartOpsMesh.cpp # PS1 importers are referenced by core code (MaterialEditorQML, MeshImporterExporter). # Include them so the MaterialEditorQML test executables link successfully.