Skip to content

Latest commit

 

History

History
619 lines (512 loc) · 306 KB

File metadata and controls

619 lines (512 loc) · 306 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Build Commands

Configure (macOS arm64 example):

cmake . -B build_local -DCMAKE_PREFIX_PATH="/path/to/Qt/6.9.1/macos;/path/to/ogre/SDK_arm64" -DCMAKE_OSX_ARCHITECTURES=arm64

Build:

cmake --build build_local --target QtMeshEditor -j4

Run (macOS):

./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor

Build and run tests:

cmake . -B build_local -DBUILD_TESTS=ON -DCMAKE_PREFIX_PATH="..."
cmake --build build_local --target UnitTests -j4
./build_local/bin/UnitTests                    # all tests
./build_local/bin/UnitTests --gtest_filter="Manager*"  # single test suite

Run with MCP server:

./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor --with-mcp          # GUI + MCP
./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor --mcp               # headless MCP only
./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor --with-mcp --http-port 8080  # with HTTP API (loopback-only; POST /api/tools/<name> executes, GET lists)
./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor --with-mcp --http-port 8080 --http-token-file ~/.qtmesh_http_token --http-bind 0.0.0.0  # #984: require `Authorization: Bearer <file content>` (or X-Api-Key), expose beyond localhost. NB `--http-token <secret>` is REFUSED (argv is readable by every local user via ps); use the file, QTMESH_HTTP_TOKEN, or QSettings mcp/httpToken

CLI pipeline (qtmesh):

# A 'qtmesh' symlink is created automatically during build
qtmesh info model.fbx                          # show mesh info (text)
qtmesh info model.fbx --json                   # show mesh info (JSON)
qtmesh convert model.fbx -o model.gltf2        # convert between formats
qtmesh convert model.fbx -o model.glb --compress draco  # convert + Draco-compress the glTF (#506; needs -DENABLE_DRACO)
qtmesh fix model.fbx -o fixed.fbx              # re-import/export with standard optimizations
qtmesh fix model.fbx --all                     # apply all extra fixes (remove degenerates, merge materials)
qtmesh anim cache.abc --info                    # Alembic vertex-cache metadata (frames/verts/fps/duration; needs -DENABLE_ALEMBIC)
qtmesh anim cache.abc --info --json             # same, as JSON
qtmesh anim model.fbx --list                   # list animations
qtmesh anim model.fbx --list --json            # list animations (JSON)
qtmesh anim model.fbx --rename "Take 001" "Idle" -o out.fbx  # rename an animation
qtmesh anim base.fbx --merge walk.fbx run.fbx -o merged.fbx
qtmesh anim model.fbx --trim --start-time 0.5 --end-time 2.0 --animation "Walk" -o out.fbx  # cut the clip to [0.5s..2s] (boundary poses baked, re-timed to start at 0; GUI: dope-sheet "✂ Trim to range" on the selected keys)
qtmesh anim model.fbx --resample 30 -o optimized.fbx  # resample to 30 keyframes
qtmesh anim model.fbx --decimate-step 5 -o lighter.fbx  # keep every 5th keyframe
qtmesh anim model.fbx --resample 30 --animation "Walk" -o out.fbx  # resample specific animation
qtmesh anim model.fbx --bake-fps 30 -o uniform.fbx     # re-grid every track to uniform 30 FPS
qtmesh anim model.fbx --bake-fps 60 --animation "Run" -o out.fbx  # bake one animation at 60 FPS
qtmesh anim model.fbx --in-between --gap-frames 30 -o filled.fbx  # AI in-betweening: fill the clip with 30 predicted keyframes (RMIB ONNX; smooth spline fallback) (#409)
qtmesh anim model.fbx --in-between --gap-frames 12 --start-time 0.5 --end-time 1.5 --animation "Jump" -o out.fbx  # fill a specific window of one animation
qtmesh anim model.fbx --in-between --gap-frames 12 --no-model -o out.fbx  # force the deterministic spline fallback (skip the ML model)
qtmesh anim model.fbx --dump-canonical clips.json  # #839: extract every skeletal animation onto the 22-joint canonical skeleton (world-frame quats, bind-geometry-derived axis conjugation) — feeds scripts/build-motion-library-v5.py
qtmesh anim rigged.fbx --generate "walking confidently" -o out.glb  # text-to-motion (#411, experimental): match a permissive CMU clip → retarget onto the rig. Actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle (+ synonyms). Library downloads on first use; needs a humanoid rig
qtmesh anim rigged.fbx --generate "jump" --duration 2 -o out.glb  # retime the template to N seconds
qtmesh anim rigged.fbx --generate "walk" --arm-space 35 -o out.glb  # #854: Mixamo-style arm-space — widen (+) / tuck (−) the arms as a post-process
qtmesh anim rigged.glb --arm-space -20 --animation generated_walk -o out.glb  # standalone: re-adjust an existing clip's arm space (absolute + idempotent)
qtmesh anim rigged.glb --facing --animation generated_walk  # #837: report the clip's hip world-forward direction (+Z / −Z) — no write
qtmesh anim source.fbx --retarget target.glb --bonemap mixamo_to_unity --anim Walk -o target_walking.glb  # #523 retarget onto an INCOMPATIBLE skeleton (names/axes/proportions/Y-Z up/A-T pose). Auto-map when --bonemap is omitted; bundled maps mixamo_to_humanik|mixamo_to_unity|mixamo_to_unreal or a .bonemap file. --translation none|root|all, --source-rest bind|first-frame, --no-align, --name, --print-bonemap, --save-bonemap, --json. Omit --anim to retarget every clip
qtmesh anim model.fbx --generator sine --target "bone:*/mixamorig:Spine/rotation.z@mixamo.com" --amplitude 20 --frequency 1 -o out.glb  # #524 procedural generator (sine|noise|ramp|follow-path|spring); target kind:object[/sub]/channel[@clip] — bone|node|morph|pose|light|material; '*' = the imported mesh/its node. Repeat --generator/--target for more; --bake turns them into keyframes; a <out>.generators.json sidecar keeps them editable
qtmesh anim model.glb --list-generators [--json]  # #524 read the generators sidecar (no mesh load)
qtmesh anim model.glb --list-constraints [--json]  # #525 read the .constraints.json sidecar (no mesh load)
qtmesh anim model.fbx --constraint ik --owner "bone:*/mixamorig:LeftHand" --target "node:Cup" --bake-constraints --animation Idle -o out.glb  # #525 add look-at|ik|parent-of|copy-rotation|copy-position|limit-rotation constraints + bake into keys
qtmesh pose model.fbx --animation "Walk" --time 0.5 -o posed.stl  # export single frame
qtmesh pose model.fbx --animation "Dance" --count 4 -o pose_%02d.stl  # export N evenly spaced frames
qtmesh pose lib.poselib --library list [--json]   # #521: list named poses in a .poselib sidecar (no mesh load)
qtmesh pose model.fbx --library apply --lib lib.poselib --apply smile_l -o posed.fbx  # apply a named pose + export
qtmesh turntable model.fbx -o turntable.png  # PNG sprite sheet (12 frames default)
qtmesh turntable model.fbx -o frame_%02d.png --frames 24 --axis y --camera-height 25
qtmesh turntable model.fbx --animation "Walk" --frames 8 -o walk.png  # #936: sample the clip over time, fixed front camera (--orbit combines rotation+animation)
qtmesh turntable model.fbx --at 0.5 -o pose.png  # #936: normal orbit of the pose at t=0.5s
qtmesh turntable model.fbx --studio -o thumb.png --frames 1  # #933: three-point studio lighting (warm key/cool fill/rim) for marketplace thumbnails; the DEFAULT is now a shaded key+fill (untextured models render shaped gray, not flat silhouettes)
qtmesh isometric model.fbx -o iso.png  # 8-direction static sprite grid (rows=directions)
qtmesh isometric model.fbx --resolution 256 -o iso.png  # square 256px cells
qtmesh isometric model.fbx --animation "Walk" --frames 8 -o iso.png  # 8×8 animated atlas
qtmesh isometric model.fbx -o iso.png --elevation 35  # camera angle in degrees (default 30)
qtmesh isometric model.fbx -o iso.png --padding 1.5  # zoom out (auto-fit × 1.5)
qtmesh isometric model.fbx -o iso.png --camera-distance 5  # fixed orbit distance
qtmesh vat model.fbx --anim Walk --fps 30 -o out/            # OpenVAT bake (#371): 16-bit PNG (top half positions, bottom half normals) + `<anim>-remap_info.json` + source.gltf + bind sidecar
qtmesh vat model.fbx --anim Walk --mode rigid --target godot -o out/   # #522 mode family: skeletal (default) | rigid (quaternion+pivot per submesh CHUNK, Horn fit, residual reported) | mesh-anim (Alembic/VAT_POSE clip) | morph (weight clip, default "MorphAnim"). --encoding rgba8|rgba16|exr, --target agnostic|unity|unreal|godot (non-agnostic ships that engine's template; rigid → openvat_rigid.gdshader). Sidecar gains _mode/_target/_rigid/_track/_morph_targets
qtmesh validate model.fbx                      # validate mesh (exit 1 if errors found)
qtmesh validate model.fbx --json               # validation results as JSON
qtmesh lod model.fbx --info                    # show LOD levels
qtmesh lod model.fbx --info --json             # LOD info as JSON
qtmesh lod model.fbx --count 3                 # generate 3 LODs → model_lod1.fbx, model_lod2.fbx, model_lod3.fbx
qtmesh lod model.fbx --count 2 --reductions 0.25,0.5 -o out.fbx  # custom reductions, named output
qtmesh lod model.fbx --count 3 --algo ogre -o out.fbx     # Ogre's MeshLodGenerator (default; better silhouette preservation in practice)
qtmesh lod model.fbx --count 3 --algo meshopt -o out.fbx  # meshoptimizer backend (preserves UV seams + skin weights, softer silhouette)
qtmesh lod model.fbx --auto                    # auto-generate LODs
qtmesh lod model.fbx --remove -o clean.fbx     # strip LODs and save
qtmesh material model.fbx --preset "Metallic-Roughness" -o out.fbx  # apply a built-in material preset (writes .material sidecar)
qtmesh material model.fbx --env studio_neutral --env-intensity 1.5 --env-tint "#fff5e6" -o out.fbx  # HDR env + per-material IBL tuning (#473)
qtmesh material model.fbx --env /path/to/custom.hdr -o out.fbx      # load HDRI + write .hdr-env.json sidecar
qtmesh material --list-presets                 # list built-in preset names (incl. PBR templates + HDR Environment presets)
qtmesh hdri --list                             # bundled HDRI catalog + on-disk status (#472)
qtmesh hdri --download studio_neutral          # fetch optional CC0 HDRI into <AppData>/hdri/
qtmesh hdri --download-all                     # download every downloadable catalog entry
qtmesh light scene.gltf --list [--json]       # list scene lights from file metadata / sidecar (#490)
qtmesh light --list-rigs [--json]              # list built-in light rig preset ids
qtmesh light scene.gltf --add point --pos 0,2,0 --colour "#fff" --intensity 1.5 -o lit.gltf
qtmesh light scene.gltf --remove PointLight -o out.gltf
qtmesh light scene.gltf --edit KeyLight --intensity 2.0 --colour "#ffaa66" -o out.gltf
qtmesh light scene.gltf --apply-rig three_point_studio [--replace] -o out.gltf
qtmesh material model.fbx --generate-texture "rusty bronze armor" -o out.fbx  # AI mesh-aware (depth-conditioned) texture → diffuse (needs SD build + base model; run `uv --unwrap` first if no UVs)
qtmesh material model.fbx --generate-texture "..." --model mybase.safetensors --controlnet-strength 0.8 --width 768 --height 768 -o out.fbx  # explicit SD base model + ControlNet strength + size
qtmesh material --texture albedo.png --generate-pbr [<mesh>] -o out.fbx  # AI PBR map synthesis (normal/roughness/height) from a diffuse → writes maps next to the albedo; with a <mesh> also binds them + re-exports (needs ONNX build + first-run model download; roughness works offline)
qtmesh material --texture albedo.png --generate-pbr --no-height --tile-size 512  # selective maps + larger model tiles; omit the mesh to just write the PNGs
qtmesh material --texture low.png --upscale 4 -o high.png  # AI super-resolution (Real-ESRGAN 2x/4x); writes <stem>_upscaled.png if no -o (needs ONNX build + first-run model download)
qtmesh material --photo-depth photo.png -o depth.png  # #1018: monocular depth from a PHOTO (Depth-Anything-V2-Small, Apache-2.0); 8-bit near=bright, the same convention MeshDepthRenderer emits, so a photo and a rendered mesh are interchangeable as ControlNet conditioning. Add --depth-letterbox to pad instead of stretch
qtmesh material --texture albedo.png --inpaint --mask holes.png -o fixed.png  # #1017: LaMa texture inpainting (Apache-2.0 code+weights). WHITE in the mask = replace, black = keep; unmasked texels stay bit-exact. Writes <stem>_inpainted.png if no -o. --mask-dilate N (default 2) grows the mask first, since a mask ending exactly at the bad pixels leaves the model conditioned on them
qtmesh material model.fbx --describe "rusty bronze armor" -o out.fbx  # AI material from a natural-language description via the local LLM (needs a GGUF model; loads last-used/first available, or --model <name>)
qtmesh material model.fbx --describe "glossy red plastic" --model qwen2.5-3b-instruct-q4.gguf -o out.fbx  # pick the GGUF model explicitly
qtmesh scan ./assets                           # scan directory for asset issues
qtmesh scan ./assets --profile example-minimal # built-in platform validation preset
qtmesh scan ./assets --list-profiles           # list bundled profile ids
qtmesh scan ./assets --config qtmesh.yml       # use YAML config file
qtmesh scan ./assets --json                    # JSON output
qtmesh scan ./assets --report report.json      # write JSON report to file
qtmesh scan ./assets --sarif report.sarif      # write SARIF report to file
qtmesh scan ./assets --fix --dry-run           # preview auto-fixes
qtmesh scan ./assets --include "*.fbx,*.glb"   # filter by extension
qtmesh scan ./assets --fail-on warning         # exit 1 on warnings or errors
qtmesh pack-textures --r ao.png --g rough.png --b metal.png -o orm.png  # pack 3 grayscale maps into RGB (Unity ORM)
qtmesh pack-textures --r metal.png --g rough.png --bc 0 --no-alpha -o mr.png  # Unreal MR (constant blue)
qtmesh pack-textures --r rough.png --invert-r -o gloss.png  # invert: roughness → glossiness
qtmesh normal-from-height --src bump.png -o normal.png  # Sobel: height/bump → tangent-space normal map
qtmesh normal-from-height --src bump.png --strength 4 --invert-g -o dx_normal.png  # DirectX +Y-down convention
qtmesh atlas --inputs a.png,b.png,c.png -o atlas.png  # shelf bin-pack N textures into a single atlas
qtmesh atlas --inputs a.png,b.png --size 1024 --padding 4 --manifest atlas.json -o atlas.png  # with UV manifest
qtmesh atlas-apply mesh.fbx -o atlased.fbx --manifest atlas.json --atlas atlas.png  # consume manifest: remap UVs + rebind diffuse
qtmesh optimize character.fbx -o character_opt.fbx  # vertex-cache reorder + animation keyframe simplify
qtmesh optimize character.fbx --reduction 0.5 -o character_lo.fbx  # also decimate 50%
qtmesh optimize character.fbx --target-tris 5000 --simplify-rotation-deg-tol 1.0 -o lo.fbx  # tighter anim tolerances
qtmesh optimize character.fbx --simplify-preset aggressive -o lo.fbx  # 1e-2/1°/1e-2 — ~20× key reduction, visible drift
qtmesh uv model.fbx --info                     # UV channels + UV0 coverage + island count + overlap %
qtmesh uv model.fbx --info --json              # same, as JSON
qtmesh uv model.fbx --unwrap -o unwrapped.glb  # xatlas auto-UV unwrap (#400). Non-overlapping UVs into UV0.
qtmesh uv model.fbx --unwrap --channel 1 --resolution 2048 -o lightmap.glb  # write into UV1 (lightmap workflow)
qtmesh uv model.fbx --project box -o box_uv.glb              # geometric box projection (#465)
qtmesh uv model.fbx --project cylinder --axis 1 -o cyl.glb   # cylinder unwrap around Y
qtmesh uv model.fbx --project reset -o reset.glb             # reset UV0 to unit box
qtmesh uv model.fbx --set-seams "0:1-2,0:2-3" -o seamed.glb # mark seam edges (submesh:vertA-vertB)
qtmesh skin model.fbx --max-influences 4 --falloff 4 -o skinned.fbx  # auto skin weights for a mesh+skeleton (#402/#819); default algo is skintokens (ML skinner — downloads ~2.3 GB models on first use; falls back to geodesic-voxel)
qtmesh skin model.fbx --algo geodesic-voxel -o skinned.fbx  # Maya-style volume-aware bind (offline, fast — the ML path's fallback)
qtmesh skin model.fbx --algo inverse-distance -o skinned.fbx  # legacy #402 straight-line heuristic (also the automatic fallback for planes/cloth)
qtmesh skin model.fbx --algo geodesic-voxel --voxel-res 128 --smooth-iterations 5 -o skinned.fbx  # finer voxel grid + more Laplacian weight smoothing (0 = off)
qtmesh skin model.fbx --evaluate [--json]      # skin-quality metrics on the EXISTING weights (#819 Slice E): influence histogram, Laplacian smoothness, geodesic bleed
qtmesh skin ours.fbx --compare mixamo_ref.fbx [--json]  # per-vertex weight diff vs a reference-skinned copy (position-matched verts, name-matched bones; docs/SKINNING_QUALITY.md)
qtmesh rig model.obj --skeleton humanoid -o rigged.fbx  # native auto-rig: embed a skeleton template into an unrigged mesh (#407)
qtmesh rig model.obj --skeleton humanoid --skin -o rigged.fbx  # one-click rig + skin (chains #402); templates: humanoid|biped|quadruped|generic; --up-axis x|y|z (default y)
qtmesh rig model.obj --algo unirig -o rigged.fbx  # ML skeleton prediction via ONNX UniRig (#408, MIT model); default --algo pinocchio (offline). UniRig falls back to the template when the model/ONNX is unavailable
qtmesh facerig head.glb -o rigged.glb          # #889: auto-generate the 52 ARKit blendshapes on a humanoid FACE mesh (fit ICT template via non-rigid ICP + Sumner-Popović deformation transfer, attach as named morph targets). A poor fit (non-face mesh) is rejected. Bundled template downloads on first use. Feeds `qtmesh mocap --face`
qtmesh facerig head.fbx -o rigged.glb --max-shapes 20 --max-residual 5 --json  # cap shape count / tighten the humanoid gate / machine-readable report
qtmesh lipsync take.wav --mesh head.glb -o spoken.glb  # #1019: SPEECH -> ARKit blendshape weight keyframes (NVIDIA Audio2Face-3D via ONNX). Drives any mesh with ARKit morph targets, including `qtmesh facerig` output. `--fps N` `--clip NAME` `--emotion joy=0.6` `--map overrides.json` `--json`
qtmesh generate3d image.png -o out.glb         # AI image-to-3D (#764, TripoSR/ONNX): reconstruct a mesh from a single image (needs ONNX build + model; downloads on first use, clear message if not hosted)
qtmesh generate3d --prompt "a goblin warrior in bronze armor" -o out.glb  # prompt-to-3D: GENERATE the source image from text first (FLUX.2-klein-4B via the bundled stable-diffusion.cpp — 3-file component set downloadable in AI Model Settings, ~5.2GB Apache-2.0; falls back to any SD checkpoint, override with --image-model <name>). The image is saved as <out>_source_prompt.png, then the normal pipeline runs. GUI: "— or generate the image from text" prompt in the AI: Image → 3D section; MCP: generate_mesh_from_image {prompt}. A subject-steering suffix (single subject, full body, centered, plain background) is appended automatically
qtmesh generate3d photo.png --remove-bg -o out.glb  # run U²-Net background removal first (needed for photos with a background; TripoSR wants an isolated subject)
qtmesh generate3d image.png --resolution 128 --no-color -o out.glb  # faster/preview marching-cubes grid; skip vertex color
qtmesh generate3d photo.png --quality int8 -o out.glb  # smaller encoder tier: fp32 (best,~1.7GB) | int8 (~430MB); downloads on demand
qtmesh generate3d image.png --texture-size 2048 -o out.glb  # quality pass (ON by default): Taubin smoothing + iso-surface reprojection + xatlas diffuse-texture bake (writes a *_diffuse.png sidecar next to the output)
qtmesh generate3d image.png --no-smooth --no-refine --no-bake-texture -o out.glb  # raw marching-cubes output with per-vertex color (pre-quality-pass behavior)
qtmesh generate3d image.png --upscale-texture -o out.glb  # + Real-ESRGAN 2x on the baked diffuse (sharper color; upscale model downloads on demand)
qtmesh generate3d image.png --inpaint-seams -o out.glb  # #1017: after the diffuse bake, AI-fill the atlas gutter (texels no UV chart covered) with LaMa so filtering/MIPs pull CONTINUED texture across seams instead of the dilation pass's smeared border colour. Opt-in (~200 MB first-use model); falls back silently to the dilated bake when unavailable. MCP: inpaint_seams
qtmesh generate3d image.png --no-pbr -o out.glb  # skip the PBR stage (#404 normal+roughness synthesized from the baked diffuse and bound into the material — ON by default; the polished-surface look; writes *_normal/_roughness.png sidecars)
qtmesh generate3d photo.png -o out.glb --backend trellis2 --preset balanced --seed 42  # TRELLIS.2 backend (Microsoft, MIT code+weights): the highest-quality tier and the DEFAULT whenever its sidecar runtime is installed (ai/trellis2/install.py; Linux + NVIDIA GPU ≥24GB VRAM). Python does INFERENCE ONLY; QtMeshEditor natively does alpha matte (own U²-Net — upstream's RMBG-2.0 is CC-BY-NC and never loads), game-ready weld/debris-cull/simplify, xatlas UV + multi-channel PBR bake (basecolor RGBA/roughness/metallic/normal via Trellis2Bake — deliberately WITHOUT NVIDIA nvdiffrast/nvdiffrec, which are research-only-licensed and CI-gated out; audit: docs/trellis2-dependencies.md). Full-res source preserved as <out>_source.qtm3d
qtmesh generate3d photo.png -o out.glb --backend trellis2 --preset high --target-tris 25000 --texture-size 4096  # TRELLIS.2 game-ready presets: --target-tris 10000/25000/50000 (0 = original density); presets fast=512 / balanced=1024_cascade / high=1536_cascade; QTMESH_TRELLIS2_MOCK=1 exercises the whole pipeline without a GPU
qtmesh generate3d photo.png --game-preset roblox-meshpart -o out.glb  # NAMED game-ready budget (GameReadyPresets.h — ONE table for the GUI Mesh picker / CLI / MCP `game_preset`; `--list-game-presets`). Plain tiers (max, prop-minimal ~500, prop-tiny ~1k, prop-low ~5k, low ~10k, medium ~25k [default], high ~50k) are budgets; the ROBLOX tiers (roblox-accessory <=4k, roblox-meshpart <=20k) are HARD CEILINGS (GameReadyOptions::strictTriangleBudget re-runs the sloppy simplifier until it fits — Roblox rejects an upload past the limit) and cap the texture at Roblox's 1024 px — on the FINAL images (`MeshGenPredictor::Options::maxTextureSize` → `capResultTextures` in `predict()`, since xatlas treats the bake size as a HINT; the 2x upscale is skipped under a cap), not just the request (GUI snaps the picker; CLI/MCP clamp + note). An unreachable strict ceiling (disconnected pieces: sloppy collapses to 0) FAILS the generation with a clear error — never an over-budget "Roblox" asset. CLI/MCP: a preset is a BASE — an explicit `--target-tris` (any 1..10M) / `--texture-size` (any 64..8192) alongside it overrides that number (past a Roblox limit: honoured + warned, never clamped); the GUI keeps the preset-only picker Works on TRELLIS.2, Pixal3D, TripoSR and TripoSG
qtmesh generate3d photo.png --target-tris 25000 -o out.glb  # game-ready pass, ALL backends: weld + debris-cull + meshopt-simplify toward the target, then re-bake the diffuse on the simplified mesh (TripoSR field bake is density-independent) + bake the dense source's relief into a tangent-space detail normal map sharing the same atlas (Trellis2Bake::bakeDetailNormal). This is the fix for 'decimated Tripo output turns into a blob / skins badly' — simplify hard, keep detail in textures. 0 = original density
qtmesh generate3d photo.png -o out.glb --backend pixal3d --resolution 1024  # Pixal3D backend (TencentARC, SIGGRAPH 2026, MIT code+weights): a TRELLIS.2 FORK that replaces the global DINOv3 cross-attention with view-aligned PROJECTION conditioning (out = cross_attn(x, global) + proj_linear(proj)) - an alternative to TRELLIS.2 worth trying on characters; it also predicted material better (measured on the same image+seed: metallic ~0.00 on yellow plastic vs TRELLIS.2's false 0.52). Runs on the SAME trellis-cli runtime and REUSES TRELLIS.2's decoders byte-for-byte - one models dir serves both, flow weights taking a `pixal3d_` prefix (src/trellis_cli.cpp: FP = pix ? "/pixal3d_" : "/"). Needs its own ~11 GB flow set (AI Model Settings -> "Pixal3D"). Pixal3D ships NO res-512 texture flow, so --resolution 512 writes geometry only (the runtime says so; measured PBR=0 at 512 vs 4,987,138 at 1024) - use --resolution 1024 for textures. --pixal-fov DEG sets the projection camera (omit = trellis-cli's own 49.13, Pixal3D's training value; guessing mis-places the camera the whole backend depends on); --no-naf drops the NAF guided upsampler + its pixal3d_naf.gguf dependency
qtmesh generate3d image.png --backend triposg --flow-steps 25 --guidance 7 -o out.glb  # TripoSG backend (1.5B rectified-flow DiT, MIT): higher-fidelity GEOMETRY, slower; models download on first use; --guidance 0 disables CFG. TripoSG is GEOMETRY-ONLY (no colour decoder) — a colour bake queries TripoSR's image-conditioned colour field on the same image + projects the input photo onto the visible front (the back is inferred, so it's approximate; a "Generate texture (AI)" option in the GUI does a front-photo + SD-generated-back multi-view bake for a better back). int8 tier is DROPPED for TripoSG (fp32 only — quantized geometry degrades to blobs, no ARM speed win)
qtmesh segment model.fbx                       # AI part segmentation (#410/#818): per-part vertex/face counts; category auto-detected (body/vegetation/vehicle/building)
qtmesh segment model.fbx --json                # full vertex/face → label arrays + per-part summary + resolved category (stable schema)
qtmesh segment tree.glb --category vegetation  # force a category (skip the point-cloud category classifier): trunk/branch/foliage/root/flower
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 segment model.fbx --split-parts --solidify -o parts.glb  # PartOps (#863): also give each thin-shell part real WALL VOLUME (solidify) so a cut shows a solid cross-section instead of the hollow interior — for single-sided game-asset shells
qtmesh segment model.fbx --explode-parts -o scene.glb  # PartOps (#864): split, then EXPLODE each part into its own scene node offset outward → a multi-node glTF scene. --explode-distance <d> (default 0.15, × assembly diagonal); combine with --solidify
qtmesh lattice model.fbx --apply bend.lattice.json -o bent.glb  # lattice (free-form) deform: replay a lattice saved from the GUI's Lattice Deform section ('Save Lattice…') / MCP lattice_get onto a mesh — same bend on N assets
qtmesh lattice model.fbx --info --resolution 3,3,3 [--json]     # print the REST lattice boxing the mesh (mesh-local box + points) as a starting point for scripted edits; `qtmesh lattice --info bend.lattice.json` describes a saved file
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 ("<clip>_Body" for the body clip)
qtmesh mocap clip.mp4 --face --mesh head.glb --map overrides.json --no-head --json  # custom channel mapping sidecar; JSON report (matched/unmatched channels always listed)
qtmesh mocap x.mp4 --face --mesh m.glb --frames-dir frames/  # image-sequence input (headless CI/debug; no video decode)
qtmesh cloud login                            # device flow (prints URL + code); stores session locally
qtmesh cloud login --api-key <token>          # direct API-key login (CI)
qtmesh cloud logout                           # revoke + clear saved session
qtmesh cloud status [--json]                  # connection, email, limits, last upload time
qtmesh cloud limits [--json]                  # server-reported upload size limits
qtmesh cloud list [--json]                    # list cloud projects
qtmesh cloud upload model.fbx [--name Hero] [--include "*.png,*.fbx"] [--exclude "*.tmp"]
                                              # [--no-scan] [--no-confirm] [--json]
qtmesh cloud delete <project-id>              # delete a cloud project
# PS1 runtime ripper (#431, experimental; needs -DENABLE_PS1_RIP=ON + the beetle rip fork in PS1Cores/):
qtmesh ps1 capture game.cue --bios scph1001.bin --frames 600 -o out.gltf   # boot, capture one frame, export a scene
qtmesh ps1 capture game.cue --bios scph1001.bin --scene 5s --tracked-only --smooth --drop-slivers -o out.glb  # 5s scene + cleanup toggles (#428)
qtmesh ps1 capture game.cue --bios scph1001.bin --scene 5s -o out.glb  # #412: same-object cross-frame merge is ON by default — the per-frame sparse parts of each object are unioned (object-space vertex overlap) + repeats dropped
qtmesh ps1 capture game.cue --bios scph1001.bin --scene 5s --no-merge-objects -o out.glb  # #412: opt out — keep the raw per-frame sparse parts
qtmesh ps1 capture game.cue --bios scph1001.bin --scene 5s --rigid-animation -o out.gltf  # #429 rigid animation: author per-object node tracks (in-editor preview; node tracks don't export to glTF/FBX yet)
qtmesh ps1 capture game.cue --bios scph1001.bin --script inputs.json -o out.gltf  # reproducible input: [{"frame":60,"button":"start"}, ...]
qtmesh ps1 capture game.cue --bios scph1001.bin --auto-input -o out.gltf    # mash Start/Cross to get past menus (no script)
qtmesh ps1 dump-vram game.cue --bios scph1001.bin --frames 300 -o vram.png  # snapshot the GPU VRAM mirror to PNG
# On Linux the libretro core needs a GL context — run headless under Xvfb:
#   xvfb-run -a qtmesh ps1 capture game.cue --bios scph1001.bin -o out.gltf

CLI mode is activated by: (1) invoking via the qtmesh symlink, (2) passing --cli, or (3) using a recognized subcommand (info, fix, convert, anim, validate, lod, pose, turntable, isometric, scan, material, hdri, light, pack-textures, normal-from-height, atlas, atlas-apply, memory, analyze, vertex-cache, decimate, optimize, uv, retopo, skin, rig, facerig, lipsync, segment, lattice, generate3d, mocap, ps1, cloud) as the first argument. Use --verbose to see Ogre/engine debug output. Use --no-telemetry to permanently opt out of anonymous usage data collection (the QTMESH_NO_TELEMETRY env var opts out per-process without persisting — CI/containers/tests; the unit-test main sets it so the suite never phones home).

If Xcode SDK is updated, clear CMake cache (rm build_local/CMakeCache.txt) and reconfigure.

Dependencies

  • Qt 6.9.3: Core, Widgets, Gui, QuickWidgets, Quick, Qml, Network, QuickControls2, Test
  • Ogre3D 14.5.x: 3D rendering engine
  • Assimp 6.0.5: 3D model import/export
  • Draco (optional, -DENABLE_DRACO=ON): glTF mesh compression on export (#506); CI builds the vendored contrib/draco as a standalone static lib (Assimp's own -DASSIMP_BUILD_DRACO integration is broken per-platform) and installs it beside Assimp; locally point at any Draco install via -DDRACO_ROOT=<dir>
  • llama.cpp: Optional local LLM inference (enabled by default, disable with -DENABLE_LOCAL_LLM=OFF)
  • stable-diffusion.cpp: Optional AI texture generation (disabled by default, enable with -DENABLE_STABLE_DIFFUSION=ON)
  • Google Test: Test framework (enabled with -DBUILD_TESTS=ON)
  • ogre-procedural: Bundled in src/dependencies/ogre-procedural/ for procedural mesh generation

Architecture

Singleton Pattern (Central to the codebase)

Three singletons manage core state. All run on the main thread. Access via ClassName::getSingleton() or ClassName::getSingletonPtr(). Destroy with ClassName::kill().

  • Manager (src/Manager.h/cpp): Owns Ogre::Root, SceneManager, tracks all SceneNodes and Entities. Emits sceneNodeCreated, entityCreated, sceneNodeDestroyed signals.
  • SelectionSet (src/SelectionSet.h/cpp): Tracks selected SceneNodes, Entities, and SubEntities. Emits selectionChanged and related signals. Provides selection geometry (center, orientation, scale).
  • TransformOperator (src/TransformOperator.h/cpp): Implements SELECT/TRANSLATE/ROTATE/SCALE modes with gizmos. Handles ray/box selection via Ogre scene queries. Implements QtMouseListener interface.

Qt-Ogre Integration

  • OgreWidget (src/OgreWidget.h/cpp): QWidget subclass that creates an Ogre::RenderWindow from the native window handle.
  • EditorViewport (src/EditorViewport.h/cpp): Wraps OgreWidget, runs render loop via QTimer.
  • MainWindow (src/mainwindow.h/cpp): QMainWindow + Ogre::FrameListener. Contains viewports, toolbars, dock widgets. Right sidebar hosts the QML Inspector panel directly (no tab widget). Animation Control dock at bottom auto-shows for animated entities.

Material Editor (QML)

  • MaterialEditorQML (src/MaterialEditorQML.h/cpp): QML_SINGLETON exposing full Ogre material property access (colors, lighting, depth, blending, fog, textures) with undo/redo. QML UI in qml/.

Texture Painting (Paint v2, epic #543)

  • TexturePaintController (src/TexturePaintController.h/cpp, QML_SINGLETON): the in-viewport texture painter. Ray-hit the mesh → interpolate UV → paint into a CPU TexturePaintBuffer (RGBA8) → debounced dirty-rect blit to a live Ogre::Texture. Brushes: BrushEngine (solid/gradient — Slice A #544), BrushFootprint (stamp/tiling — Slice B #545). Layers: PaintLayerStack + PaintLayerBlend (Slice C #546) composite bottom-up into the display buffer. QML UI lives in qml/PropertiesPanel.qml (texPaintCol) + the detached qml/TextureEditorWindow.qml. Undo via inline TexturePaintStrokeCommand / PaintLayerOpCommand. Sentry categories: paint.brush.gradient, paint.brush.stamp, paint.layer.* (add/delete/duplicate/reorder/rename/merge_down/flatten/visibility/blend_mode), paint.channel, paint.symmetry, paint.stabilizer, paint.projection.*, paint.decal.*, paint.preset.*, paint.palette.*, paint.bake.*.

  • Paint v2 Slice D — full PBR channel painting (#547): paint into any of 8 channels — PaintChannelNS::Channel { BaseColor, Normal, Roughness, Metallic, AO, Emissive, Height, VertexColor } (src/PaintChannel.h, pure-data + unit-tested; each maps to a canonical TUS slot albedo/normal_map/roughness/metallic/ao/emissive, Height→normal_map, VertexColor→the existing per-vertex path). Architecture: per-channel sessions — the live m_layerStack/m_buffer is the active channel's session; setActiveChannel() stashes the current channel's layer stack into m_channelSessions[channel] and restores the target's (so each channel keeps its own stack across switches). Channel router: refreshSlots() tags each TUS with its channel; findOrCreateActiveTextureUnit() auto-creates the canonical slot when the asset never shipped it. Channel-aware bake (bakeChannel) — every channel MERGES with the slot's existing texture, never a blind overwrite (so a bake only changes what the user painted): colour channels (BaseColor/Emissive) composite the painted strokes source-over the slot's current texture (reading it back via loadImageAcrossGroups, alias-aware for BaseColor's diffuse_map, and skipping the transient QMEPaint_* paint texture so it reads the real underlying image, not the live paint buffer; a brand-new diffuse with no source flattens onto opaque white); scalar channels (Roughness/Metallic/AO) are painted grayscale and collapsed via Rec.601 luminance into ONLY their lane of the packed ORM texture (.r=AO/.g=roughness/.b=metallic, other lanes preserved from the existing ORM) bound to the metallic slot the Cook-Torrance SRS reads; Height/Normal Sobel-bakes the painted grayscale to a tangent-space detail normal via NormalMapGenerator::generate then whiteout-blends it onto the existing normal_map (n.xy=base.xy+detail.xy, n.z=base.z*detail.z, normalized — untouched texels have detail normal (0,0,1) so the base normal is unchanged; painted texels add relief). Height has no slot of its own — it bakes INTO normal_map (slotName(Height)==""), so a mesh carries one combined normal map, not separate height+normal. Height is NOT a selectable channel (removed from the paintChannels() picker; setActiveChannel(Height) redirects to Normal) — a separate Height channel just produced a second normal-map bake fighting the Normal one, and there is no parallax/displacement shader that would consume a standalone height texture. Paint the Normal channel directly (its grayscale is Sobel-converted to a detail normal and whiteout-blended onto the existing normal, sourcing the base from the session's m_originalTextureName first). Height was the highest picker index (enum 6, just before VertexColor 7), so dropping it leaves the remaining picker list indices identical to their Channel enum values — the QML activeChannel === index binding stays correct with no renumbering. Live-IBL binding recipe (bindBakedChannelTexture): bind TUS by canonical name (alias-aware for the diffuse slot) → RTShaderHelper::wirePbrSlotsForFFP → MeshImporterExporter::applyNormalMapsToEntity (tangents, for normal maps) → applyPbrIfTagged only if the material was ALREADY PBR (never silently promote a plain material to Cook-Torrance on bake — that darkened it to near-black without IBL) → mat->compile() → refreshAllPbrMaterialsForHdr(). Critically, bindBakedChannelTexture prunes the just-baked slot from m_boundSlots and then TEARS THE LIVE SESSION DOWN (stashChannelSession + m_buffer.clearDirty() + closeSession()) instead of calling flushDirtyToOgre() — a trailing flush would re-upload the stale paint buffer and, since the slot was just pruned, its deferred-rebind branch would re-bind the transient QMEPaint_* texture straight back over the freshly-baked file (that was the "first bake changes the render, second bake wipes the texture" bug). The next brush stroke lazily rebuilds a session seeded from the baked result. Source-less channel sessions also start transparent (not opaque white) and defer the manual-paint-texture model rebind to the first painted stroke, so merely NAVIGATING channels never swaps the model's real slot textures for a blank paint texture. Presets (src/PaintChannelPresets.h/cpp, QML_SINGLETON, MaterialPresetLibrary pattern): 5 channel-aware presets — "Scratches into roughness", "Emissive sparks", "Edge wear", "Dirt build-up", "Sticker" — set active channel + brush params via the controller. UI: a 7-button channel picker + presets dropdown + per-channel "Bake" button in texPaintCol. Tests: PaintChannel_test.cpp, PaintChannelPresets_test.cpp (pure-data), plus TexturePaintController_test.cpp scene-fixture cases (channel switch auto-creates slot, scalar bake → ORM, height bake → normal_map, per-channel session isolation). Session scoping: m_channelSessions is cleared when the painted entity changes (m_channelSessionEntity) so one mesh's channel stacks never leak/bake onto another. Undo across channels: paint undo commands (TexturePaintStrokeCommand/PaintLayerOpCommand/TexturePaintMaskActionCommand) key on a stable (Ogre::Entity*, channel) — NOT the transient QMEPaint_* GPU texture name (which changes every channel/session switch). undo()/redo() call ensureUndoTarget(entity, channel) which reselects the entity + setActiveChannel before applying the snapshot, so undoing a prior channel's stroke after switching channels (or deselecting the mesh) correctly reactivates and restores it; a command whose entity was deleted no-ops safely.

  • Paint v2 Slice E — symmetry + line stabilizer (#548): real-time mirrored strokes and mouse-jitter smoothing. Symmetry: WRITE-backed props symmetryEnabled (default OFF), symmetrySpace (SymLocal/SymWorld, default local), symmetryAxes (bitmask SymAxisX=1|Y=2|Z=4, default X → 8 combinations), topologyMirror (default on). Every dab is mirrored across each enabled axis-subset (1 point for X, 3 for X|Y, 7 for X|Y|Z — mirrorLocalPoints iterates nonzero subsets) inside the same begin/end stroke window, so mirror dabs are captured by the existing single TexturePaintStrokeCommand — one undo step, no new command. Works from BOTH the viewport (screen raycast → m_hitCache) and the 2D panel (applyBrushSymmetryDabs falls back to findMeshPointForUV, which also seeds the hit cache). Geometric resolver uvForLocalPoint (inverse of findMeshPointForUV): reflect the primary LOCAL hit across the axis — local: about the mesh origin m_symmetryPivotLocal; world: about the plane through the entity's _getDerivedPosition() (NOT world 0, else off-origin objects mirror into empty space) — then nearest-triangle → barycentric → UV. Topology-aware mirror (src/SymmetryMirrorMap.{h,cpp}, pure-data + unit-tested): for a position-symmetric mesh with an ASYMMETRIC UV unwrap, a geometric re-raycast reads the wrong UV; instead build() makes a per-vertex position correspondence (spatial-hash grid) verified by triangle 1-ring adjacency (rejects false position collisions; coverage()≥0.6 AND verify-ratio≥0.9 to be valid()), and mirrorDab(submesh, corner[3], bary → mirror tri + permuted bary) maps the dab to the mirror triangle and permutes the barycentric weights to that triangle's stored corner order (reflection reverses winding — mandatory) so the mirror samples the mirror triangle's own UVs. Per-single-axis map cached in m_symmetryMaps keyed by axis bit, built lazily on first symmetric dab, entity-guarded (m_symmetryMapEntity), invalidated on entity/mesh change + closeSession; falls back to geometric per-dab when no correspondence. Multi-axis segment continuity (E-B): per-subset previous mirror UV (m_mirrorPrevUV/m_mirrorHavePrevUV, reset in resetStrokePaintState) so each mirror stroke fans a paintBrushAlongSegment and doesn't gap on fast moves. Plane viz: refreshSymmetryPlaneOverlay draws a faint translucent quad per enabled axis (X=red/Y=green/Z=blue, alpha 0.12, depth-write off) on m_symPlaneNode/Obj (mirrors drawHoverRingAt), torn down in closeSession. Stabilizer: stabilizerMode (StabAverage/StabTrail), stabilizerAmount (0..100, default 0 = exact passthrough / zero latency). Smooths the raw SCREEN cursor before hit-testing in updateStroke; beginStroke seeds the buffer with the press point (first dab exact); endStroke does a synchronous catch-up to the true cursor before the deferred undo commit (Krita behaviour — stays one undo step; NEVER a timer). Pure math (stabilizerWindow/stabilizeAveragePoint weighted newest-heaviest/stabilizeTrailPoint lag-distance) is static for unit testing. Sentry breadcrumbs paint.symmetry (enable/space/axes/topology/map-build) + paint.stabilizer (mode/amount, once per stroke). QML: Symmetry + Stabilizer groups in texPaintCol. Tests: SymmetryMirrorMap_test.cpp (full-coverage build, asymmetric-UV correctness, no-correspondence invalid) + TexturePaintStabilizer_test.cpp (window growth, jitter reduction, trail lag/catch-up, amount-0 passthrough, setter clamp) — pure-data, plus a TexturePaintController_test.cpp one-undo fixture case.

  • Paint v2 Slice F — projection/stencil painting + decals (#549): two image-driven paint modes that project an image onto the mesh THROUGH a camera, rasterize it in UV0 space, and write into a paint layer; both auto-create a NEW layer (never stomp the active one). Design doc: docs/PAINT_V2_SLICE_F_DESIGN.md. src/ProjectionMath.h (header-only) extracts the shared projectToViewportUV (world→viewport UV + NDC z + behind) / sampleImage out of MultiViewTextureBaker so both the #403 baker and this slice share one definition. src/ProjectionPainter.{h,cpp} (pure-data, unit-tested) is the FORKED single-projection rasterizer (NOT a mutation of MultiViewTextureBaker::bake, whose multi-view weighted accumulator is the wrong shape): project(tris, View, source, out, opts, occ?) clears out transparent then per texel interpolates world pos + projected source UV → facing cull → occlusion/depth-limit → sample → soft-edge → manual src-over composite; projectDab(...) is the footprint-bounded ACCUMULATING stencil-brush variant. Tris come from MultiViewTextureBaker::fromEntity. Occlusion (the hard part): MeshDepthRenderer renders a depth map from the camera (grayscale = LINEAR world distance via fog, near=bright/far=dark; RenderResult gained depthNear/depthFar); per texel, project world pos through the depth map's OWN viewProj, reconstruct dMap = near+(1-g)*(far-near), and reject when the texel's camera-axis distance dot(wp-eye, camDir) (NOT Euclidean — Euclidean self-occludes off-axis texels, i.e. depth acne) exceeds dMap + bias (bias > 1/255 of the range). Depth-limit rejects texels farther than a fraction-of-bounds-radius BEHIND the nearest visible surface (the sphere-with-hole case). The color View and the occlusion viewProj are kept strictly separate (the depth camera auto-frames, so its matrices differ from the live camera's). Projection surfaces (TexturePaintController): WRITE-backed props projectionMode (0 off / 1 stencil-brush / 2 camera-locked), stencilImagePath, projBackfaceCull, projUseOcclusion, projDepthLimit, read-only cameraLocked. Stencil brush: paintColorFootprintAtUV delegates to projectDab through the live (mode 1) or locked (mode 2) camera View — masked by the projected stencil alpha, painted into the active layer (normal stroke undo). m_projTris cached at beginStroke; occlusion map refreshed at stroke start (mode 1) / on snapProjectionCamera (mode 2), never per dab. projectFromPhoto projects a photo through the camera into a scratch buffer and commits via commitProjectedLayer (new Generated layer + one PaintLayerOpCommand). currentProjectionView = cam->getProjectionMatrixWithRSDepth()*getViewMatrix() + getRealDirection/Position. Decal tool (ToolDecal=6): src/DecalSession.{h,cpp} (pure-data) is a world-anchored oriented-quad state machine — begin(img)→place(surfaceHit,normal,camUp)→Editing; translate/rotate(about normal)/scale; hitTest(rectUv)→Body/RotateCorner/ScaleEdge; buildCommit(softEdge) returns an ORTHOGRAPHIC View (world→clip change-of-basis mapping the quad to NDC ±1, camDirection into the surface) + the decal image with a feathered soft-edge alpha. Controller: refreshDecalOverlay draws a world-space ManualObject quad + corner(rotate)/edge(scale) handle squares (depth-off so grabbable), torn down in closeSession; placeDecalAt/decalHitTest(ray∩rect-plane→rect-UV)/dragDecal/commitDecal(→ProjectionPainter::project+occlusion→new layer)/cancelDecal. Viewport routing: TransformOperator::mousePressEvent consumes decal clicks BEFORE the paint-stroke branch (place while Placing, grab a handle while Editing), mouseMoveEvent drags, release ends the drag (session stays open); MainWindow::keyPressEvent — Enter commits / Esc cancels (swallow-all, mirrors the knife). Both create a new layer + are undoable. Sentry paint.projection.* / paint.decal.*. QML: a collapsible Projection group (mode/stencil/Snap/Backface/Occlude/Project-from-photo) + a Decal row (Place/Commit/Cancel + hint) in texPaintCol. Tests: ProjectionPainter_test.cpp (front projection, backface, stencil gating, dab, sphere-with-hole occlusion, depth-limit, self-projection no-acne) + DecalSession_test.cpp (transitions, hit-test zones, edits, world↔rect-UV, ortho-commit corner→NDC, soft-edge) — pure-data; plus TexturePaintController_test.cpp fixture cases (projection-mode setters + graceful no-camera; decal begin/cancel plumbing).

  • Paint v2 Slice H — brush presets + colour palettes (#551): one-click reuse of a whole brush configuration, plus curated colour swatches. Design doc: docs/PAINT_V2_SLICE_H_DESIGN.md. Tablet pressure/tilt (the issue's third feature) is deliberately NOT implemented — the project is desktop-only, so pressure curves could be written and unit-tested but never verified against real pen hardware; it should return as its own issue when there is a tablet to test on. src/BrushPresetLibrary.{h,cpp} (pure-data, unit-tested): a Preset captures tool/radius/strength/falloff/shape/channel/footprint/stamp/tiling + stamp dynamics (spacing/scatter/size+opacity jitter/rotation) + colour source (solid vs gradient + ramp name). 15 bundled presets (Soft/Hard Round, Pencil Sketch, Spray Paint, Foliage Cluster, Edge Wear, Scratched Metal, Wet Brush, Smudge Soft/Hard, Stencil Hard/Soft, Eraser Soft/Hard, Cavity Dirt) defined in C++ rather than shipped as data files so they cannot go missing from an install (the GradientRamp::bundledPresets precedent); custom presets are JSON in <AppData>/paint/presets/. Enum fields are stored as plain INTS holding the controller enum values, keeping the header pure-data — so the controller enums must not be renumbered without a format bump. src/ColorPaletteLibrary.{h,cpp} (pure-data, unit-tested): 6 bundled CC0 palettes (Material Design, Pantone Classics, Skin Tones, Foliage Greens, Sky Blues, Earth Tones), JSON in <AppData>/paint/palettes/, plus pushRecent (re-picking PROMOTES rather than duplicating, so the 12-slot ring stays distinct) and extractFromImage for "palette from texture" (5-bit-per-channel colour-cube quantisation averaging the real pixels per bucket — counting exact RGB values returns ten imperceptibly different shades; fully transparent pixels skipped or a mostly-empty texture reports "transparent black" as dominant). Swatch carries no alpha on purpose: a palette curates hues, and baking alpha in would override the user's brush alpha on every pick. Both libraries use the same conventions: bundled-in-C++ + JSON custom + hashed filename stems (so two names sanitising to the same stem cannot overwrite each other) + custom-overrides-bundled precedence (as BrushAssetLibrary::resolvePath does for stamps). Controller apply layer: applyBrushPreset/saveBrushPreset/brushPresetNames/isBundledBrushPreset/deleteBrushPreset/exportBrushPreset/importBrushPreset + colorPaletteNames/colorPaletteSwatches/recentPaintColors/applyPaletteColor/savePaletteFromTexture + a paletteChanged signal. Three non-obvious behaviours: applying a preset does NOT touch the paint colour (a preset describes the brush, not what you paint with — restoring a saved colour would silently discard the user's choice), apply order sets the stamp/tiling ASSET before the footprint TYPE (else a stamp brush briefly points at the previous preset's image), and only the FOREGROUND feeds the recent ring (the background is a rarely-changed secondary slot). Deleting a bundled preset is REFUSED (they are compiled in, so a delete could only remove a user override and would appear to return on restart); the UI disables the button. UI is Qt Widgets in the brush portal (mainwindow.cpp), departing from the QML guidance because the portal's radius/strength/footprint controls are all Widgets and mixing toolkits in one panel buys nothing — same reasoning as StampLibraryDialog, whose grid this follows. The issue asks for a preset thumbnail grid with search+tags; presets have no image to show so a grid of identical tiles would carry no more information than the name (dropdown used instead), and search/tags are unjustified at 15 entries. Swatches are a 5-column grid, left-click = FG / right-click = BG (matching the existing FG/BG toolbar swatch). Breadcrumbs paint.preset.* / paint.palette.*. Tests: BrushPresetLibrary_test.cpp (bundled catalogue, every bundled stamp reference resolves against BrushAssetLibrary — a typo there applies silently and leaves the previous footprint, presenting as "the preset did nothing"; older-build JSON keeps struct defaults for missing fields; colliding sanitised stems stay separate files), ColorPaletteLibrary_test.cpp, plus TexturePaintController_test.cpp cases. Gotcha found by mutation testing: m_activeStampName is restored from QSettings, so an assertion that a preset applied its stamp PASSED against a mutant that skipped the apply entirely — the test now sets a different stamp first. Watch for this anywhere else asserting against QSettings-backed controller state.

  • Paint v2 Slice I — bake-up workflow (#552): bakes painted layers down to the deliverables a game engine consumes. Design doc: docs/PAINT_V2_SLICE_I_DESIGN.md. src/PaintBakeTargets.{h,cpp} (pure data — QImage in, QImage out, no Ogre/session/GPU, so every engine rule is unit-testable without a scene) owns all five layouts: Generic (one texture per channel), Unity (_MetallicSmoothness RGBA with R=metallic and A=smoothness=1−roughness, AO separate, normal converted to DirectX +Y down via flipNormalGreen), Unreal (_ORM RGB = occlusion/roughness/metallic; absent occlusion fills WHITE — a 0 lane darkens the whole surface), Godot (separate textures + a .tres carrying each texture's sRGB-vs-linear flag, the classic washed-out/too-dark import bug), glTF (_metallicRoughness with G=roughness, B=metallic, R free for occlusion). targetFromId rejects an unknown id rather than defaulting to Generic (a silent default reads as "the pack didn't apply"). Six channels, not the issue's seven — Height is deliberately absent: Slice D (#547) removed it as a paintable channel (it shares the Normal session, and no parallax/displacement shader consumes a standalone height map), so there is no Height data to bake; emitting a file anyway would duplicate the normal map's grayscale under a name nothing reads. The omission is recorded in the sidecar JSON, the MCP payload, and the dialog. Deliberately NOT routed through TextureChannelPacker/NormalMapGenerator: both take a FILE PATH, but a freshly composited channel lives in memory, so reuse would mean writing every channel to a temp PNG and reading it back (what bakeChannel does today); the Rec.601 luminance rule is kept identical so the two agree. Controller layer (TexturePaintController): bakePbrSet(target, dir, resolution, prefix, includeHidden, writeSidecar) returns "" on success or an error string (the MaterialEditorQML::packTextureChannels convention) and writes every texture before reporting success — a partial set on disk looks complete; bakeTargetIds/bakeTargetLabel/paintedChannelIds feed the dialog; bakePreviewUrl caps at 512px (base64 data-URIs cost ~80ms at 2048 vs ~3ms small — see PaintBufferImageProvider); bakeVertexLayerToTextureLayer rasterises vertex colours into a new texture LAYER via VertexColorBaker with a transparent background (the pre-existing bakeVertexColorsToTexture clobbers m_buffer and rebinds the model — it never produces a layer). Two traps: stackForChannel honours the live-vs-stashed split (the ACTIVE channel's stack is m_layerStack; its m_channelSessions copy is deliberately stale until the next switch, so reading the map blindly silently bakes the pre-edit state — pinned by a test), and "include hidden layers" cannot go through compositeTo (it hardcodes solo ? i==solo : visible), so the bake copies the stack, forces visibility, composites, and discards the copy rather than adding an option to the paint hot path. Surfaces: qml/PaintBakeDialog.qml (top-level Window, Inspector primitives, lazy Loader; reached from the paint panel's "Bake set…" beside the per-channel "Bake" — the old button rebinds one channel inside the editor, the new one exports every painted channel to disk); CLI qtmesh paint-bake <file> --target <id> -o <dir> [--resolution N] [--prefix NAME] [--no-sidecar] [--json] / --list-targets; MCP paint_bake (heavy). CLI/MCP share ONE core, CLIPipeline::paintBakeToDirectory, so they cannot drift. Headless has no live layer stack, so CLI/MCP read the mesh's bound texture slots instead — i.e. they repack an already-textured asset, while the GUI bakes the live stack. loadSlot tries EmbeddedTextureCache FIRST: FBX assets routinely embed their textures (Rumba Dancing.fbx does), so they are not on disk and Ogre's resource lookup fails — going straight to the resource system made every such asset report "no PBR texture slots found" despite the slots being present and correctly named. paint-bake must be in AppLaunchHandler's recognised-subcommand list or the binary silently takes the GUI path and hangs. Breadcrumbs paint.bake.start|done|error|vertex_layer. Sidecar schema qtmesh-paint-bake-v1. Tests: PaintBakeTargets_test.cpp (all five layouts, both silently-destructive conversions asserted against hand-computed values, Unreal's white-occlusion fallback, unknown-id rejection, "nothing painted" as an error not a blank set, .tres filenames matching the writer's stem), TexturePaintController_test.cpp scene cases, CLIPipeline_cmdpaintbake_coverage_test.cpp argument gates. Verified end-to-end on Rumba Dancing.fbx for unreal/unity/godot/generic; the DirectX flip checked against real texture data (700/700 sampled pixels exact at native resolution — NB comparing two resampled bakes does NOT show bit-exact inversion, since the flip precedes the scale).

  • Paint v2 Slice J — CLI/MCP parity, docs, tests (#553): closes the epic (#543) by bringing Paint v2 to project conventions. CLI qtmesh paint: --list-stamps / --list-presets / --list-palettes (all [--json]; these hit the pure-data BrushAssetLibrary/BrushPresetLibrary/ColorPaletteLibrary so they need no mesh and no render system), <file> --layer list [--json], <file> --bake --engine <t> [--resolution N] [--prefix P] -o <dir> (--engine is an accepted alias of paint-bake's --target, and both delegate to the same CLIPipeline::paintBakeToDirectory, so the two commands cannot diverge), and <file> --apply-stencil <img> --camera "ex,ey,ez,tx,ty,tz" [--channel <id>] [--fov D] [--resolution N] -o <out>. Layer MUTATION is deliberately absent from the CLI (--layer add/merge-down/flatten): paint layers are a live in-memory session and are never persisted to a mesh file, so a headless --layer add would create a layer, write nothing, and exit — the omitted verbs print that reason rather than failing opaquely. --layer list reports the honest headless answer (no layers on a freshly imported mesh, plus which channels carry texture data); baking is how painted pixels reach disk. projectFromPhotoWithCamera(path, eye, target, up, fovY) is the new headless projection entry: projectFromPhoto reads the ACTIVE VIEWPORT camera and therefore cannot run in CLI/MCP, so this builds the view/projection itself, frames the mesh from its world bounds with padding (a tight far plane silently drops the far half of the mesh), and auto-picks a stable up vector when the caller looks down the world up axis. Both paths share projectPhotoWithView, so occlusion/resolution/breadcrumb/commit behaviour cannot drift. MCP (14 paint_* tools): paint_set_enabled, paint_list_layers, paint_add_layer, paint_delete_layer, paint_reorder_layer, paint_merge_down, paint_flatten, paint_set_active_layer, paint_set_active_channel, paint_set_brush_preset, paint_set_color, paint_set_gradient, paint_apply_stencil, plus Slice I's paint_bake. MCPServer previously had ZERO TexturePaintController coupling, so this is a new bridge, not extra dispatch rows. paint_set_enabled is not in the issue's list but is load-bearing: every other tool needs a live paint session and nothing could ENTER paint mode over MCP, so without it the parity surface could not be driven by the thing it exists for. Three non-obvious behaviours: paint_set_active_channel rejects an unknown id instead of accepting PaintChannelNS::fromId's BaseColor fallback (silently painting BaseColor when the caller asked for roughness is near-impossible to notice) and rejects height pointing at normal (#547); paint_reorder_layer loops the controller's move-up/move-down rather than reaching into the stack, keeping the single-step invariants and breadcrumbs intact; paint_set_color routes through applyPaletteColor so scripted picks feed the recent-colours ring like manual ones. Every mutating tool returns the resulting layer list + active layer + active channel so a caller sees its own effect without a second round trip. Breadcrumbs: paint.layer.* was the only non-descoped gap in the whole epic and now has 10 sites; paint.derived_map.* and paint.tablet are intentionally absent (Slice G #550 closed not-planned — no cavity/curvature/AO code exists at all; tablet deliberately skipped in Slice H for lack of hardware to verify against). Tests: BrushEngine_test.cpp and PaintLayerStack_test.cpp were the only two files #553 names that had no equivalent (ProjectionPainter_test.cpp already existed; PaintChannel_test.cpp and SymmetryMirrorMap_test.cpp are the *Router*/SymmetryEngine equivalents; DerivedMaps_test.cpp was dropped with Slice G). PaintLayerStack does NOT bounds-check — layer(index) throws, and that is safe only because every TexturePaintController::setPaintLayer* guards first; the test pins this in both directions so a new caller knows the guard is theirs. On-disk layout (<AppData>/paint/): presets/*.json (brush presets, #551), palettes/*.json (colour palettes, #551), ramps/*.json (gradient ramps, #544), stamps/ + tilings/ (brush images, #545). Every one is custom-overrides-bundled with hashed filename stems, so two names that sanitise alike cannot overwrite each other. Docs: docs/PAINT_V2_CLI_MCP.md. Gotcha for future MCP work: the stdio transport needs LSP Content-Length framing — a newline-delimited JSON client hangs on the first read.

Scene Lighting (epic #482, Slice H #490)

  • LightManager (src/LightManager.h/cpp): owns user scene lights as named Ogre::SceneNode + Ogre::Light pairs tagged user_light. Create/duplicate/rename/delete/apply-properties; emits lightCreated / lightChanged / lightDeleted.
  • LightRigLibrary (src/LightRigLibrary.h/cpp): six built-in rig presets (three_point_studio, etc.). apply(rigId, replaceExisting) spawns a rig-group node + child lights and sets ambient.
  • SceneLightsIO (src/SceneLightsIO.h/cpp): bit-exact round-trip via qtmesh.scene.lights glTF metadata (chunked when >1 KiB) plus .lights.json sidecar for FBX/glb. powerScaleToGltfIntensity() maps QtMeshEditor intensity → KHR_lights_punctual lux/candela (approximate; metadata is authoritative).
  • SceneLightsCLI (src/SceneLightsCLI.h/cpp): headless qtmesh light — --list, --list-rigs, --add, --remove, --edit, --apply-rig with -o export through MeshImporterExporter::sceneExporter / mesh export + sidecar.
  • MCP tools (create_light, delete_light, list_lights, set_light_property, apply_light_rig): operate on the live editor scene via LightManager / LightRigLibrary / SceneLightsIO::captureFromScene().
  • Intensity units: GUI/CLI/MCP use Ogre powerScale (Inspector “Intensity”). glTF export writes both qtmesh.scene.lights (exact) and best-effort KHR punctual intensity derived from powerScale × diffuse luminance. FBX lights are Assimp best-effort; use the .lights.json sidecar for bit-exact round-trip.
  • Sentry breadcrumbs: scene.light.create|delete|duplicate|rename|edit|shadow_toggle|apply_rig|gizmo_toggle on core ops; ui.action on menu/toolbar clicks.
  • Light linking (Slice I #491): per-light include/exclude lists map to Ogre Light::setLightMask / Entity::setLightMask (32 channel bits; bit 0 reserved; max 31 simultaneous link rules). Persisted in qtmesh.scene.lights JSON as linkMode, linkedEntities, linkChannelBit. Exclude sets excluded entities to the channel bit only (no overlap with the light's inverted mask). Combining include + exclude on the same entity is best-effort. RTSS PBR may not honour masks on every pass.
  • Light collections (#491): user-created groups via LightGroupLibrary / LightGroupController — select 2+ lights, parent under a tagged light_group scene node; transform/enable/disable as one unit. Persisted in scene JSON as rigGroups[] with groupKind: "collection".
  • Viewport solo (#491): ViewportLightSoloController — per-viewport runtime toggle to show only one light during that viewport's render pass (OgreWidget::frameEnded begin/end hooks). Inspector picker under Scene → Lighting.
  • IES profiles (#491): IesProfile LM-63 parser + IesLightApply maps beam/field angles to spotlight cones (approximation; not full shader 1D texture yet). Inspector Browse/Clear; polar plot overlay in LightVisualizer. Persisted as iesProfilePath in scene JSON.
  • Area lights (#491, ENABLE_AREA_LIGHTS): multi-point proxy approximation on rectangle/disk/line shapes; parent light hidden while active. Inspector shape/size/samples; wire gizmo in LightVisualizer. Persisted as areaShape, areaWidth, areaHeight, areaSampleCount.

Debug Overlays

  • NormalVisualizer (src/NormalVisualizer.h/cpp): Draws vertex normals as colored lines (|X|=Red, |Y|=Green, |Z|=Blue). Toggled globally via Options → Show Normals menu or MCP toggle_normals tool. Supports real-time animation: requests software-skinned normals via addSoftwareAnimationRequest(true) and updates each frame for skeletal entities. Overlays attach to dedicated child scene nodes to avoid unsafe static_cast<Entity*> crashes in ObjectItemModel and Manager::getEntities().
  • MeshInfoOverlay (src/MeshInfoOverlay.h/cpp): Floating overlay showing mesh statistics (vertices, triangles, submeshes, materials, bones, animations) on the active viewport. Shows stats for selected entities or aggregated scene stats. Toggled via Options → Show Mesh Info menu or MCP toggle_mesh_info tool. Implemented as a top-level Qt::Tool window to avoid ghost-text artifacts from Ogre's direct-to-native rendering (WA_PaintOnScreen).
  • BoneWeightOverlay (src/BoneWeightOverlay.h/cpp): Per-entity bone weight heat-map overlay.

ViewCube

  • ViewCubeController (src/ViewCube/ViewCubeController.h/cpp): QML_SINGLETON that bridges the 3D navigation cube overlay with the active OgreWidget/SpaceCamera. Tracks camera orientation quaternion, manages overlay positioning, and provides snap-to-face/corner/direction + arcball drag rotation.
  • ViewCubeWindow.qml (qml/ViewCubeWindow.qml): QML Canvas2D rendering a 3D cube with face/edge/corner hit-testing. Uses quaternion-to-rotation-matrix conversion with negated qx to match Ogre's camera rig convention.
  • Visibility requires both the toggle (setVisible) and an active widget — hides automatically when viewports are closed and reappears when a new viewport gets focus.
  • The QML window uses Qt::FramelessWindowHint | Qt::Tool | Qt::WindowStaysOnTopHint and software rendering (QQuickWindow::setSceneGraphBackend("software")) to avoid GL conflicts with Ogre.

Transform System

  • TransformOperator (src/TransformOperator.h/cpp): Singleton implementing SELECT/TRANSLATE/ROTATE/SCALE modes. Owns three gizmos (TranslationGizmo, RotationGizmo, ScaleGizmo). Supports WORLD/LOCAL transform space. Mouse interaction: ray-cast gizmo for axis selection, plane intersection for drag transforms.
  • ScaleGizmo (src/ScaleGizmo.h/cpp): Scale gizmo with cube handles at axis endpoints. Follows TranslationGizmo pattern (ManualObject per axis, highlight/fade).
  • Keyboard shortcuts (Unity convention): Q=Select, W=Translate, E=Rotate, R=Scale, F=Frame selection (in Edit Mode with a fillable selection: Fill instead), X=Toggle World/Local space (in Edit Mode with a selection: Delete; Ctrl+X=Dissolve).
  • SpaceCamera::frameSelection(): Computes bounding sphere of selection and positions camera to fit it in view.

Edit Mode (Phase 4 topology)

  • EditModeController (src/EditModeController.h/cpp): QML_SINGLETON managing Object/Edit mode state. Tab toggles. In edit mode, 1/2/3 switch Vertex/Edge/Face component selection.
  • HalfEdgeMesh (src/HalfEdgeMesh.h/cpp): Half-edge data structure built per-operation from EditableMesh, used for adjacency queries and topology mutations. Operations: extrudeFaces, extrudeEdges, bevelEdges, bevelVertices, splitEdge, splitFace, cutPath (knife), mergeVertices, mergeVerticesByDistance, deleteFaces/Edges/Vertices, dissolveEdges/Vertices, subdivideFaces, fillSelection. All push a single EditMeshTopologyCommand for undo.
  • Subdivide: 1-to-4 triangle split. Adjacent non-selected faces are retriangulated against the new midpoints to avoid T-junctions (1/2/3 split-edge cases). Wired to a toolbar button (⊞) — face mode subdivides selected tris, edge mode subdivides every triangle incident to a selected edge.
  • Fill: vertex mode fan-triangulates the selected verts (3 → triangle, 4 → quad, N → N-2 tris); edge mode detects a closed boundary loop via degree-2 walk and caps it. Toolbar button (◆) and F shortcut. Cross-submesh inputs and duplicates of existing triangles are rejected.

Animation systems beyond skeletal (epic #517)

The animation pipeline started skeleton-only; the #517 epic broadens it. Slices A/C/D shipped: MorphAnimationManager (src/MorphAnimationManager.{h,cpp}, morph targets / blend shapes — Ogre Pose + VAT_POSE tracks, qtmesh morph CLI, MCP, undo via commands/MorphCommands), NodeAnimationManager (non-skinned node TRS), PoseLibrary (named poses — save/apply/blend/mirror/mask + .poselib sidecar, see its own entry below). All are QML_SINGLETONs registered in mainwindow.cpp.

  • NodeAnimationManager (src/NodeAnimationManager.{h,cpp}, Slice C #517): transform (TRS) animation on non-skinned Ogre::SceneNodes — animated props/doors/spinning machinery/camera moves/animated lights. Clips are Ogre::Animation + AnimationState owned by the SceneManager (not a mesh), driven by NodeAnimationTracks. Sub-slices shipped: C1 data layer (create/delete clip, per-clip {node→handle} allocator that replaced the collision-prone qHash & 0xFFFF — see PR #584 — add/overwrite keyframe with a 1ms merge epsilon), C3 undo (commands/NodeAnimCommands.{h,cpp}: Create/Delete-clip + SetKeyframe, plus Move/Delete-keyframe for the dope sheet), C6 MCP (list_node_animations/add_node_animation_clip/set_node_keyframe, plus the full-parity set: set_node_animation_playing/delete_node_animation_clip/move_node_keyframe/delete_node_keyframe/get_node_animation), C-CLI qtmesh nodeanim <file> --list [--json] plus authoring: qtmesh nodeanim <file> --add <node>:<position|rotation|scale> --keyframes "0:0,0,0;1:5,0,0" [--clip NAME] [--length S] -o out (rotation values are Euler degrees; repeat --add/--keyframes for more channels/nodes; seeds untouched channels from the node's current TRS; exports via sceneExporter so glTF is native + FBX/.mesh get the .nodeanim.json sidecar). (#520) C5 export (shipped for glTF/glb): buildNodeClipAnimations() in MeshImporterExporter.cpp walks the SceneManager's node clips and emits one aiAnimation per clip with aiNodeAnim TRS channels targeting each scene node BY NAME (the same name buildSceneAiScene() gives the entity's aiNode), so node-transform animation now survives sceneExporter to glTF/glb (verified end-to-end: authored clip → export → re-read glb has the animation + exact keyframe times/values). Node keyframes store ABSOLUTE local TRS (unlike the bind-relative skeletal path). Node-anim reimport (all formats): reconstructNodeClipsFromAiScene/reconstructNodeClipsFromFile rebuild clips from the aiNodeAnim channels on glTF/glb import; for FBX + .mesh — whose exporters (custom FBXExporter / Ogre MeshSerializer) have no concept of SceneManager node animation — the clips are persisted to a <basename>.nodeanim.json sidecar (writeNodeAnimSidecar, schema qtmesh.node.animations.v1, mirrors the .lights.json pattern) on export and rebuilt by reconstructNodeClipsFromSidecar on import. Skeletal+morph coexistence gotcha (fixed): Assimp imports a glTF morph-weight animation as a 0-channel aiAnimation; AnimationProcessor::processAnimation must SKIP those (a 0-track skeletal clip is useless) — otherwise it creates an empty skeletal clip sharing the morph clip's name (e.g. "MorphAnim"), which buildAiScene re-emits empty while injectMorphWeightAnimations appends the real one → two same-named glTF animations → Ogre aborts the whole re-import ("animation already exists", 0 entities loaded). injectMorphWeightAnimations also dedupes by name as a belt-and-suspenders. Regression-guarded by scripts/anim-combined-roundtrip.sh + tests/fixtures/combined_skel_morph.glb. C-GUI (this slice): node clips are ticked in the render loop (MainWindow::frameRenderingQueued now advances SceneManager-level AnimationStates — they were previously never ticked; only per-entity states were), authored in the Animation-mode "Node Transform Animation" Inspector section (qml/AnimationControlPanel.qml: clip picker + New/Delete + Play + "Key selected node") and visualised/edited in the dope sheet's "Node Transforms" band (qml/AnimationDopeSheet.qml, mirrors the morph band: interactive diamonds — drag to re-time, right-click to delete, double-click empty to key the node's current transform). The band tracks NodeAnimationManager.activeClip; keys go through the undoable command path. QML-facing helpers on the manager: nodeRows/animatedNodes/clipLength/isClipEnabled/scrubClip (read/scrub) + createClipUndoable/deleteClipUndoable/keyNodeCurrentTransform/moveNodeKeyframe/deleteNodeKeyframe (undoable authoring). Sentry scene.anim.node / scene.anim.node.cmd. scrubClip is intentionally a no-op on the node — an enabled AnimationState makes _applySceneAnimations re-drive the node every frame and lock it against gizmo edits; the authoring model is "paused = editable, Play = preview" (the node clip's own AnimationState.enabled flag is the Play toggle, and frameRenderingQueued advances SceneManager states BEFORE the isPlaying early-return so node clips play from their own toggle, independent of the global skeletal Play button).

  • AnimationRetargeter (src/AnimationRetargeter.{h,cpp}, Slice F #523): retarget a skeletal clip between INCOMPATIBLE skeletons through an explicit source→target bone map (AnimationMerger only handles compatible ones). Pure core (Retarget::RigDesc/LocalPose/BoneMap, autoMap, retargetFrames, headless-tested in AnimationRetargeter_test.cpp) + a thin Ogre adapter (describeSkeleton orders bones parents-first by depth, sampleAnimation snapshots + restores the live pose incl. manual bones, retarget writes a NEW clip relative to the target bind: kf.rot = bind⁻¹·local, kf.translate = local − bind). Algorithm: per mapped bone T_world = G·(S_world·S_rest⁻¹)·G⁻¹ · A · T_rest, world→local top-down. G = humanoid-frame alignment (hips→head up, left→right upper legs or arms right) between the two rest poses (absorbs Y/Z-up, ±Z facing, armature rotations; identity for non-humanoid maps). A (alignDirections, default on) = per-bone rotation of the target rest direction onto the source rest direction (A-pose source on a T-pose target); mapped leaves inherit the nearest mapped ancestor's A. Two real-rig bugs fixed while verifying on the Bite-by-Bite guard (UniRig, no clavicles): a BRANCHING bone must align to its CENTRAL child (chest→neck), never a sided one — aligning the chest to the first child (an arm) rolled the torso 36°; and the ROOT translation follows the SOURCE's root-most mapped bone, not the target's first mapped bone (Quaternius rigs parent IK feet above the hips, so target order picked Foot.L). Translation modes None (rotation only) / Root (default, scaled by the rigs' rest height ratio along up) / All. Source rest = bind or first frame. Auto-map: exact normalized name → humanoid role (MotionInbetween::canonicalIndexForBoneV2, body + fingers; shared roles pair in hierarchy order; local fallbacks Torso→chest, Palm→hand — added HERE, not in the shared matcher, because trained motion models depend on its table) → side+synonym key → Levenshtein ≤20 % on the same side; one-to-one. .bonemap = qtmesh-bonemap-v1 JSON; bundled mixamo_to_humanik = mixamo_to_unity and mixamo_to_unreal; resolveMap rebinds names leniently (mixamorig: map on a mixamorig1: rig). Undo: commands/RetargetAnimationCommand snapshots the created clip keyframe-for-keyframe (redo rebuilds it without the source), entity resolved by name. Surfaces: wizard qml/RetargetAnimationDialog.qml + RetargetController (QML_SINGLETON; Animation-mode "Retarget" section; auto-map on pick, per-row overrides, bundled/file maps, side-by-side preview on its own timer that nudges an overlapping target and restores clip/state flags/position on stop); CLI qtmesh anim <src> --retarget <tgt> (CLIPipeline::cmdAnimRetarget); MCP retarget_animation (scene entities with undo, or transient file import + output_path; dry_run returns the map). Sentry scene.anim.retarget.{automap,map_edit,bonemap_load,bonemap_save,preview,apply,error,cli,cmd}; gamification animation_blend. QTMESH_RETARGET_DEBUG=1 prints G, per-bone rest directions, and a check that the WRITTEN clip matches the core (≤0.06° on the guard). Docs: docs/RETARGETING.md.

  • Procedural generator tracks (src/AnimGenerators.{h,cpp} + src/AnimGeneratorManager.{h,cpp}, Slice G #524): fill an animatable property from a formula — Sine, Noise (1-D Perlin fBm blended with value noise + a per-seed lattice shift, because pure gradient noise is exactly 0 on every lattice point, i.e. a camera shake would pass through rest on a regular beat), Ramp (linear/smooth), Spring (closed-form damped harmonic motion, under/critical/over-damped, zero initial velocity) and Follow path (Catmull-Rom anchors as cubic Béziers, arc-length table for even speed, optional face-along-tangent with Ogre's −Z forward). AnimGenerators is pure data (target grammar kind:object[/sub]/channel[@clip] — bone names may contain ':' so only the FIRST ':' splits the kind —, evaluation, sampling, qtmesh-anim-generators-v1 JSON, applyParam for CLI/MCP keys), tested in AnimGenerators_test.cpp. Layer model: every type ADDS to the base (base(t) + Σ active); follow-path REPLACES position (and orientation with orient). Two bindings: TRACK-BACKED targets (bone TRS on a skeletal clip, node TRS on a node clip — default clip Generators, created + enabled when absent —, morph weight on the weight clip) are MATERIALISED into the real Ogre track: the original keys are snapshotted as the BASE (one base per track key, so several generators on one track share it and muting one never wipes another), then the track is rewritten as base keys outside the generators' window + dense samples (max fps) of base⊕generators inside it. Base values come from Ogre's own getInterpolatedKeyFrame on the restored base track (exact for spline clips); playback, scrubbing, the dope sheet and every exporter see the motion with no new playback code. Bone keys are bind-relative (rotation post-multiplied in the bone's local frame; a path position becomes P − initialPos), node keys absolute local TRS. Morph keys must carry every pose of the submesh (Ogre interpolates a pose missing from a keyframe toward 0 — the #1019 lesson), so the morph writer works on the VAT_POSE tracks directly with pose refs stored by NAME. RUNTIME targets (pose weight via the new PoseLibrary::applyPoseWeighted — bind→pose blend, held —, light intensity/diffuse/specular, material diffuse/ambient/specular/emissive/shininess on EVERY technique's pass 0, since the RTSS technique is a copy) are evaluated by tick() from MainWindow::frameRenderingQueued on the generator clock (advances while playing, follows the timeline slider while paused; a muted runtime target with a constant base is left alone so it doesn't fight user edits). Bake folds one generator into its base (a real track for bone/node/morph — marked as pre-existing so removing the baked generator keeps the keys —, a keyed curve for runtime targets, which has no native track and is removed with its generator); the generator stays attached but inactive and cannot be re-enabled. Undo: every mutation (add/remove/params/mute/bake/path) is ONE AnimGeneratorDocCommand swapping two documents (generators + base snapshots); applyDocument restores every track that leaves the document and re-materialises the rest, so redo/undo replay exactly. Invariant: a base exists exactly while some generator references its track. Persistence: <file>.generators.json sidecar beside every single-entity export (only generators aimed at that entity, its node or its materials; meta records the exported entity/node names so a re-import under another name rebinds them) and scene export; loaded on import AFTER the pose library and node clips it may target. Gotcha fixed during this slice: QJsonArray{QJsonArray{0.0, v}} is the COPY constructor (a flat [0, v]), not a one-element array — it crashed the first runtime tick. Surfaces: Inspector Animation-mode "Generators" section (qml/GeneratorsPanel.qml, loaded from qrc:/AnimationControl/) — add (type/kind/object/sub/channel/clip pickers fed by objectsFor/subsFor/clipsFor/channelsFor), list with live/mute checkbox + Bake + remove, per-type parameter fields, path point list + "Edit in viewport" (overlay polyline + points; TransformOperator routes a press on a point to beginDrag, camera-plane drag anchored at press, one undo step on release); CLI qtmesh anim <file> --generator … --target … [--<param> v] [--bake] -o out / --list-generators (CLIPipeline::cmdAnimGenerators; node/light/material targets export the scene); MCP list_generators, add_generator, set_generator (params + enabled), bake_generator, remove_generator. Sentry scene.anim.generator.{add,remove,edit,enable,bake,path,capture,save,load,cli,undo,redo,error}; gamification animation_blend. Tests: AnimGenerators_test.cpp (pure), AnimGeneratorManager_test.cpp (scene: materialise + exact mute restore, non-accumulating edits, shared base, undo, bake survives mute/remove, node path clip lifecycle, morph all-poses keys, runtime material/light/pose, document + sidecar rename, panel QML load). Docs: docs/ANIM_GENERATORS.md.

  • Animation constraints (src/AnimConstraints.{h,cpp} + src/ConstraintManager.{h,cpp}, Slice H #525): look-at, analytical 2-bone IK (on the END bone; optional pole), parent-of (offset captured at add), copy-rotation, copy-position (axis mask), limit-rotation (local Euler XYZ), each with influence. Per-owner STACK evaluated bottom-up — the top-most wins; new constraints go on top. AnimConstraints is pure maths + qtmesh-anim-constraints-v1 JSON (refs node:Name / bone:Entity/Bone, first ':' splits), tested in AnimConstraints_test.cpp. Evaluation runs from a SceneManager::Listener::preUpdateSceneGraph (after _applySceneAnimations, before skinning): node owners first (re-based each frame: if the node still holds our last write the stored base is used, else it moved and that is the new base — influence never creeps, removal restores), then bone owners per entity — pose the skeleton from its states HERE (setAnimationState), write constraints, setSkipAnimationStateUpdate(true), and _notifyDirty() the state set (without the dirty bump Entity::_updateAnimation never re-reads the skeleton and a paused clip shows nothing). Bones stay NON-manual, so muting just lets the clip take over; releasing an entity re-poses it from its clips (else a paused rig keeps the last constrained pose). Gotcha: Ogre only flags the changed node — descendants keep STALE derived transforms until the next graph update, so every write calls _update(true,false) on the written node (pinned by a test). Bake samples [0, clip length] at fps (scene clocks + _applySceneAnimations per sample), writes bind-relative keys for every touched bone incl. IK ancestors into the skeletal clip, node owners into their node clip or a new Constraints clip, then mutes the baked constraints; one ConstraintDocCommand (doc before/after + track snapshots). Export (Export Selected / Save Scene) offers Bake & Export / Export without baking / Cancel. Sidecar <file>.constraints.json (single-entity export keeps that entity's/node's owners; meta names rebind on renamed import). Not part of retargeting. Surfaces: Inspector Animation-mode "Constraints" (qml/ConstraintsPanel.qml), CLI qtmesh anim … --list-constraints | --constraint … --owner … --target … --bake-constraints (CLIPipeline::cmdAnimConstraints), MCP list_constraints/add_constraint/set_constraint/move_constraint/remove_constraint/bake_constraints (AI capability animation). Sentry scene.anim.constraint.*. Tests: AnimConstraints_test.cpp, ConstraintManager_test.cpp, MCPServerConstraints_coverage_test.cpp. Docs: docs/ANIM_CONSTRAINTS.md.

  • PoseLibrary (src/PoseLibrary.{h,cpp}, Slice D #521): named poses — frozen snapshots of every bone's TRS, keyed by bone NAME (not handle, which changes across LOD/skeleton variants). Use cases: T-pose / A-pose / neutral reference frames, named facial expressions (smile_l), reference frames to snap to before keying. Storage is per-entity (QHash<Entity*, EntityPoses>; two characters sharing a mesh keep separate libraries) with a parallel insertion-ordered QStringList so the UI sees save-order, not hash buckets. Bones in a snapshot but missing from the current skeleton are skipped silently (partial apply beats refusing). Sub-slices: D1 data layer (save/apply/delete/list), D3 undo (commands/PoseLibraryCommands.{h,cpp}), D4 mirror (flipBoneName heuristic: _l↔_r, .L↔.R, Left*↔Right*; TRS flip is pos.x → -pos.x, quat (w,x,y,z) → (w,x,-y,-z), scale.x → -scale.x; centre-line bones get the reflected TRS in place), D5 apply-with-mask (applyPoseMasked — an empty filter means "no bones", NOT "all"), D-Project .poselib sidecar JSON (schema qtmesheditor.poselib.v1, atomic QSaveFile write, all-or-nothing load that validates BEFORE wiping), D-MCP/D-CLI (list_poses/save_pose/apply_pose/delete_pose/mirror_pose/blend_poses/apply_pose_masked/save_pose_library/load_pose_library; qtmesh pose <lib>.poselib --library list [--json] and qtmesh pose <mesh> --library apply --lib <lib> --apply <name> -o <out>). D2 (this slice) — blend + GUI + thumbnails: blendPoses(entity, a, b, weight, dst) writes an interpolated pose (translation/scale lerp, rotation Quaternion::Slerp(..., shortestPath=true) — without shortest-path a >180° blend spins the long way round; the result covers the UNION of both bone sets, a bone in only one source taken verbatim; weight is CLAMPED to [0,1], not extrapolated — past the endpoints real rigs fold in on themselves). applyPoseBlended(entity, name, seconds) starts a time blend: the live pose is snapshotted at call time and each tickBlend(dt) recomputes from (captured start, target, elapsed) with smoothstep easing rather than accumulating, so nothing drifts if something else writes to the skeleton mid-transition; the target snapshot is copied, so deleting/overwriting the source pose mid-blend can't dangle. duration <= 0 snaps (same contract as applyPose), and a snap-apply cancels any in-flight blend so the next tick doesn't fight the user's action. MainWindow::frameRenderingQueued calls tickBlend before and independently of the isPlaying gate, with raw dt not scaledDt — a blended apply is an authoring transition, not clip playback, so it must complete while the transport is paused (which is when authors pose) and must not be scaled by the playback-speed knob. GUI: the Animation-mode "Pose Library" Inspector group (qml/PoseLibraryPanel.qml, loaded via qrc:/AnimationControl/PoseLibraryPanel.qml, gated on SkinWeightsController.hasSkinnedSelection — a pose IS a skeleton state) lists each pose with a thumbnail + Apply/Mirror(⇄)/Delete, plus rows for save, blend-in duration, blend-two, and the apply-with-mask bone picker. Every panel action routes through a *Undoable entry point that pushes a command, so each is ONE Ctrl+Z step; the new commands are MirrorPoseCommand, BlendPosesCommand, ApplyPoseMaskedCommand (captures only the masked bones — capturing the whole skeleton would let undo revert edits the mask was supposed to protect) and ApplyPoseBlendedCommand (undo cancels the in-flight blend FIRST, then restores, else the next tick drags the skeleton back off the restored snapshot). Thumbnails: poseThumbnailForSelection poses the entity to the snapshot, renders one ModelTurntableRenderer frame (studio lighting so untextured rigs read as shapes), restores the pre-existing live pose, and returns a data:image/png;base64,… URI cached per (entity, pose); the cache entry is dropped whenever the pose's content changes (save/mirror/blend/delete/library-load) and purged by prefix on forgetEntity (entity pointers get reused). Headless/no-GL returns an empty string and the panel just shows a placeholder — never an error. .poselib export/import raise exportLibraryRequested/importLibraryRequested and MainWindow runs the QFileDialog (QML can't parent one) — the HdrEnvironmentController::browseRequested split. Auto-persistence: MeshImporterExporter writes a <basename>.poselib sidecar on every SINGLE-ENTITY export (writePoseLibrarySidecar, next to the .nodeanim.json / .lights.json writes; removes a stale sidecar when the library is empty so a deleted library doesn't resurrect) and reads it back on import (loadPoseLibrarySidecar, beside reconstructNodeClipsFromSidecar), so "save → close → reopen" keeps the library with no manual export. Scene (multi-entity) export is covered too: sceneExporter/sceneImporter write and read the same <basename>.poselib path (foo.scene.glb → foo.scene.poselib via completeBaseName), but with each entity's library nested under the name of the SCENE NODE it hangs from — {schema, entities:[{node, poses:[…]}]} (PoseLibrary::saveSceneLibraries / loadSceneLibraries, wired through writeScenePoseLibrarySidecar / loadScenePoseLibrarySidecar). The NODE name is the key because that's what sceneExporter writes into the glTF and sceneImporter recreates — an Entity* can't survive a reload and entity names aren't scene-unique. entities and the single-entity poses are both OPTIONAL under the SAME schema string, so the two writers coexist and a pre-existing single-entity sidecar still loads unchanged. Node names are sorted before writing so the file is byte-stable across runs (QHash iteration order is randomised per process, and a sidecar that reshuffles itself every export is hostile to version control). Scene load parses every entry BEFORE committing any, so a malformed entry can't leave half the entities restored; nodes in the file but absent from the reopened scene are skipped rather than failing the load. Sentry scene.anim.pose / scene.anim.pose.cmd; gamification cluster pose_library.

  • All-animation-via-MCP (#517 follow-up): the MCP surface now covers every animation control the GUI has. Beyond the node-anim set above: global playback (set_playback_speed, set_loop_region, get_playback_state, select_animation, select_bone) via AnimationControlController, and morph weight keyframing over time (set_morph_weight_keyframe, clear_morph_weight_keyframe) via MorphAnimationManager (the pre-existing set_morph_weight is instantaneous only). All are light (main-thread, no worker). A headless HTTP-MCP regression harness (scratchpad/anim_mcp_test.sh) drives the full set — author → play → export → verify glb roundtrip — and asserts no crash; 33 checks green.

  • Morph authoring UX (Blender-parity, #519): morph targets are authored/edited/reordered in the Edit-Mode "Vertex Morph Animation" Inspector group (NOT Animation Mode — Animation Mode's list shows only real clips, morph animations are filtered out of PropertiesPanelController::animationData() by name). Authoring is a non-destructive sculpt session: EditModeController::beginMorphSculpt() snapshots base positions; + Add captures the current edit as a target (delta vs base); endMorphSculpt() / exiting Edit Mode restores the base (the target lives on as a Pose + weight). EditableMesh::commitToEntity refreshes the pose buffer (AnimationStateSet::_notifyDirty() + entity->_updateAnimation()) for vertex-animated entities so edits render live while the frame loop is paused. Reorder via MorphAnimationManager::moveMorphTarget/moveMorphTargetToIndex (undoable ReorderMorphTargetsCommand — VAT_POSE keyframes reference poses by index, so it rebuilds all targets in the new order). Weight keyframing over time (Slice 2): setMorphWeightKeyframe(name, time, weight) writes to one shared clip MorphAnimationManager::kWeightClipName ("MorphAnim") — a VAT_POSE track per target's pose, each keyframe referencing the pose at influence == weight; the per-target "◈ Key" button records the weight at the timeline playhead (AnimationControlController.sliderValue), diamonds show in the dope sheet (allMorphRows() prefers the MorphAnim track's per-pose keyframe times). glTF export: buildAiScene emits blend-shape targets (aiMesh::mAnimMeshes via attachMorphTargetsToAiMesh) AND a morph-weights animation (aiMeshMorphAnim from the MorphAnim clip). FBX blend-shape weight export is a follow-up.

  • VertexAnimationManager (src/VertexAnimationManager.{h,cpp}, Slice B #519): full-mesh per-vertex animation (cloth / sims / fluid bakes / Alembic caches — every vertex moves, no skeleton). Reuses Ogre's VAT_POSE path so the existing timeline/dope-sheet/loop play it with no new playback code. FrameSet/FrameData are the source-agnostic decoded-cache types; buildClipFromFrames(mesh, name, frames) reads submesh-0 bind positions and builds one Ogre::Pose per frame (delta vs bind) + one VAT_POSE track keyed per frame time. sampleHeuristic(frameCount) (< 32 → poses, else stream — the issue's rule) is static + unit-tested. Sentry scene.anim.vertex_anim.

  • AlembicImporter (src/AlembicImporter.{h,cpp}, Slice B2): Alembic (.abc) reader behind -DENABLE_ALEMBIC (default OFF; cmake/Alembic.cmake FetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditional install(EXPORT) finds it in an export set). readFrameSet decodes the first IPolyMesh into a FrameSet (pure data — rejects variable-topology caches, fan-triangulates n-gon faces); importToScene builds the base mesh + VAT_POSE clip + entity. .abc routes through MeshImporterExporter::importer (guarded — a non-Alembic build logs a clear "rebuild with -DENABLE_ALEMBIC" and skips). The reader is #ifdef ENABLE_ALEMBIC-guarded so the default build is unaffected; a round-trip test writes+reads a synthetic .abc (only compiled/run in the Alembic-on coverage CI lane). B3 (shipped): readInfo(path) reads cache metadata (frames/verts/tris/fps/duration/storage) from the schema header + first sample without decoding all frames → qtmesh anim <file>.abc --info [--json] (CLIPipeline::cmdAnim); MCP import_alembic (heavy — decodes into the live scene, reports node/entities/vertexClips) + play_vertex_animation (delegates to toolPlayAnimation since a vertex clip is an ordinary AnimationState). Frame cap: readFrameSet(maxFrames) and importToScene cap the decode at 512 frames and set ReadResult::truncated / log a warning when it bites (no silent cap) — VAT_POSE holds every frame resident, so true per-frame vertex-buffer streaming remains future work.

Undo/Redo System

  • UndoManager (src/UndoManager.h/cpp): Singleton wrapping QUndoStack. Push commands, undo/redo via Ctrl+Z/Ctrl+Shift+Z.
  • TransformCommands (src/commands/TransformCommands.h/cpp): TranslateCommand, RotateCommand, ScaleCommand, DeleteCommand. Translate and Scale support command merging. State captured on mouse press, command pushed on mouse release in TransformOperator.

QML Inspector Panel

  • PropertiesPanelController (src/PropertiesPanelController.h/cpp): QML_SINGLETON providing transform values, selection state, scene tree model, primitive parameters, animation data (enable/loop/rename), skeleton debug toggles. Bridges all scene data to QML.
  • SceneTreeModel (src/SceneTreeModel.h/cpp): QAbstractItemModel exposing hierarchical scene tree (Nodes → Entities → SubEntities) to QML. Supports multi-select, material name get/set on submeshes, debounced rebuild on scene changes.
  • PropertiesPanel.qml (qml/PropertiesPanel.qml): Main inspector with collapsible sections:
    • Scene — recursive tree view (SceneTreeNode.qml) with expand/collapse, Ctrl+click multi-select, material typeahead dropdown on submeshes
    • Transform — position/rotation/scale spinbox fields with up/down arrow keys and buttons
    • Primitive — context-sensitive fields per primitive type (size, radius, height, segments, UV)
    • Animations — per-entity groups with enable/loop checkboxes, double-click rename, play/pause, skeleton/weights toggles
  • CollapsibleSection.qml, SceneTreeNode.qml, TransformField.qml — reusable QML components.
  • Loaded as QQuickWidget directly in the right dock (replaces old tab widget with Transform/Material/Edit/Animation tabs).

Theme System

  • ThemeManager (src/ThemeManager.h/cpp): QML_SINGLETON providing canonical theme colors synced from QPalette. All colors (window, panel, header, text, button, highlight, border, accent) derived from the active QPalette.

HDR & IBL (#466)

  • HDREnvironmentManager (src/HDR/HDREnvironmentManager.h/cpp, Slices A–D): global HDR environment load, cubemap registration, async IBL precompute (irradiance + prefiltered specular + BRDF LUT), tonemap defaults, and skybox. Bundled names resolve under media/hdri/; user downloads land in <AppData>/hdri/. IBL disk cache: <AppData>/hdr_cache/<sha1>/ (delete that folder to force a rebake; see HdrCache::cacheRootDirectory()).
  • HdrEnvironmentController (src/HDR/HdrEnvironmentController.h/cpp, Slice E #471): QML bridge for Inspector Environment (Object mode): HDRI picker, Browse, skybox toggle, background blur, tonemap controls. Wired to all viewports via HdrViewportController.
  • HdrMaterialScript (src/HDR/HdrMaterialScript.h/cpp, Slice D): serialises per-material pbr_environment_intensity / pbr_environment_tint lines in .material sidecars; stripped before feeding scripts to Ogre.
  • HdrBundledLibrary (Slice F #472): CC0 bundled HDRIs, qtmesh hdri --list/--download, first-run defaults (studio_neutral + ACES + 0 EV). See THIRD_PARTY_HDRI.md.
  • Slice G (#473) parity: qtmesh material --env/--env-intensity/--env-tint; MCP set_hdr_environment, get_hdr_environment, set_tonemap, set_env_intensity, set_env_tint. Sentry breadcrumbs: render.hdr.load, render.hdr.precompute, render.hdr.bind, render.hdr.tonemap, render.hdr.preset, render.hdr.skybox, ui.action on inspector changes.
  • Stretch (not in 3.15): qtmesh render headless tonemapped preview PNG — turntable/isometric paths cover most batch preview needs today.

Indie Game Dev Features

  • BatchExporter (src/BatchExporter.h/cpp): Multi-file conversion wrapping CLIPipeline. Supports progress reporting.
  • MaterialPresetLibrary (src/MaterialPresetLibrary.h/cpp): QML_SINGLETON providing one-click material presets (Plastic, Metal, Wood, Glass, Unlit, Wireframe) plus PBR templates and HDR Environment presets (Polished Metal (HDR), Glass (HDR), Skin (HDR-friendly), etc.). HDR presets set env intensity/tint and auto-load studio_neutral.hdr when no IBL is active; Sentry breadcrumb render.hdr.preset on apply.
  • HdrBundledLibrary (src/HDR/HdrBundledLibrary.h/cpp, Slice F #472): CC0 bundled HDRIs under media/hdri/ (~25 MB total at 2k), first-run defaults (studio_neutral + ACES + 0 EV), and qtmesh hdri --download for optional Poly Haven fetches into <AppData>/hdri/. See THIRD_PARTY_HDRI.md.
  • TextureChannelPacker (src/TextureChannelPacker.h/cpp, slice G): pure-data packer that takes 1-4 grayscale source images (or constants) and writes a single packed RGBA texture (PNG/TGA/JPG). Each output channel is sampled via Rec.601 luminance from its source image, with an optional invert flag (useful for roughness↔glossiness). Smaller sources are bilinear-scaled up to match the largest input. Surfaced via the qtmesh pack-textures CLI subcommand, the pack_textures MCP tool, and the "Pack Texture Channels…" button in Material Mode → Mode Tools (slice G/G2/G3). The dialog (qml/TextureChannelPackerDialog.qml) is a top-level Inspector-styled Window with a 256×256 live preview thumbnail (slice G2 — MaterialEditorQML::previewPackedTextureChannels returns a data:image/png;base64,… URL the QML Image element shows directly), DropArea on each channel row for drag-and-drop from Finder/Explorer, three one-click presets (Unity ORM, Unreal MR, Spec→Gloss invert) that filename-heuristically wire existing source paths to the right channels, and per-row trash-can reset buttons (slice G3).
  • NormalMapGenerator (src/NormalMapGenerator.h/cpp, slice H): pure-data generator that produces a tangent-space normal map from a grayscale height/bump source via a 3×3 Sobel filter. strength scales the gradient (clamped to [0..32]); invertR and invertG flip the corresponding channels — invertG is the OpenGL (+Y up, default) ↔ DirectX (+Y down) switch. Output is RGB8. Surfaced via qtmesh normal-from-height CLI subcommand, the generate_normal_map MCP tool, and the "Generate Normal Map…" button in Material Mode → Mode Tools. Dialog (qml/NormalMapGeneratorDialog.qml) reuses the Inspector primitive style from the channel packer with a strength slider, OpenGL/DirectX toggle, source DropArea, and 256×256 live preview thumbnail.
  • TextureAtlasPacker (src/TextureAtlasPacker.h/cpp, Phase 6 slice E): pure-data packer that places N input textures into a single composite atlas image plus a JSON manifest of per-tile UV remaps. Algorithm: shelf bin-pack with height-descending sort (deterministic; no rotation; tiles padded on every side so MIPs don't bleed). Manifest schema: { width, height, padding, tiles: [{ source, x, y, w, h, u0, v0, u1, v1 }] } — downstream tooling (asset-pipeline scripts, Inspector "Apply Atlas" follow-up, etc.) can ingest it directly to rewrite mesh UVs onto the atlas. Use case: collapse many per-prop textures into one binding to reduce GPU draw-call count (works directly against the slice B draw-call analyzer's merge suggestions). Surfaced via qtmesh atlas --inputs a.png,b.png,... -o atlas.png [--size 2048] [--padding 2] [--manifest atlas.json], the pack_atlas MCP tool, and the "Pack Atlas…" button in Material Mode → Mode Tools. Dialog (qml/TextureAtlasDialog.qml) reuses the Inspector primitive style from the channel packer + normal-map dialogs: a drop-area input list with per-row trash buttons, size/padding number fields, output + optional manifest path fields, and a 256×256 live preview thumbnail (MaterialEditorQML::previewAtlas returns a data:image/png;base64,… URL the QML Image element shows directly).
  • ApplyAtlas (src/ApplyAtlas.h/cpp, Phase 6 slice E2): the consumption side of slice E. Reads a packer manifest (the JSON written by manifestToJson) and applies it to an Ogre::Entity — for every submesh whose diffuse texture matches a manifest tile (by basename or full path), scale+bias UV0 from [0..1] into the tile's [u0..u1, v0..v1] sub-rect AND rebind the submesh's diffuse TUS to the atlas image. Material walks are two-pass so submeshes that share an Ogre::Material (very common — Mixamo exports re-use one Skin_MAT across many submeshes) all see the original texture name before any mutation. UVs outside [0..1] are clamped by default (matches every other game-engine atlas tool); pass clampOutOfRangeUVs=false (CLI --no-clamp) to leave them untouched and surface them as outOfRangeUVs in the report. After each unique material is mutated we call RTShaderHelper::wirePbrSlotsForFFP + mat->compile() / reload() so the FFP+RTSS lighting path recomputes against the new binding (without this, lighting reads back the cached pre-swap binding and looks subtly off). By default non-diffuse texture slots (normal / AO / emissive / metallic / roughness) on affected materials are stripped because they sample UV0 — now diffuse-atlas-relative — and would render against the wrong region. --keep-extras (CLI) / keep_extras: true (MCP) / the dialog checkbox opt out, only sensible when you have also atlased those channels with a matching layout. The per-submesh report includes a strippedExtraTextures count so the caller can confirm what got removed. Surfaced via qtmesh atlas-apply mesh.fbx -o atlased.fbx --manifest atlas.json --atlas atlas.png [--match {basename|fullpath}] [--no-clamp] [--keep-extras] [--json], the apply_atlas MCP tool, and the "Apply to Mesh…" button inside the Pack Atlas dialog (qml/ApplyAtlasDialog.qml). The Apply dialog is launched from inside the pack dialog (not from the panel toolbar) — slice E2 is a niche follow-up, so it avoids taking general-UI space. The launcher auto-fills the freshly-packed atlas + manifest paths so a "pack → apply" flow is one extra click.
  • Optimize pipeline (Phase 6 slice G, lives entirely inside CLIPipeline::cmdOptimize): sequences the slice C / C4 / D optimizations end-to-end on a single asset and writes the result. Stages run in order — vertex-cache reorder (per submesh, Forsyth) → decimate (single entity, slice D) → animation simplify (AnimationMerger::simplifyAnimation) — on the same loaded Ogre scene with no intermediate file I/O. Defaults to vertex-cache + simplify-anim when no flags are given; --reduction <r> / --target-tris N / --target-verts N adds decimation. Emits a per-stage applied/summary report (text or --json). Same surface on MCP via optimize_mesh (file in / file out). Rumba Dancing.fbx → optimize with --reduction 0.5 shrinks 6.3 MB → 1.4 MB (77.7%), ACMR 0.822 → 0.648, 42% of redundant keyframes stripped.

QtMesh Cloud

  • Connection (CloudCredentialStore, QtMeshCloudClient): device-flow login (qtmesh cloud login) or API-key login (--api-key, MCP cloud_login). Session bearer tokens persist in per-user QSettings (non-prompting; see CloudCredentialStore for rationale). QtMeshCloudSession runs network I/O on worker threads; GUI/MCP callbacks stay on the main thread.
  • Packaging (DependencyResolver, ProjectPackager, CloudUploadPlanner): upload packages discover sidecar textures/materials/animations, build a sanitised manifest (ProjectPackager::jsonPassesPathSanitisationLint), and honour CLI/MCP --include / --exclude globs via CloudUploadPlanner::selectedPathsForUpload.
  • Upload protocol (QtMeshCloudClient, QtMeshCloudSession): POST /v1/projects → POST …/files/upload-urls → signed PUT per file → POST …/files/complete → optional scan report PUT …/files/:id/report (5 MB client cap). Progress surfaces through QtMeshCloudSession::uploadProgress; CLI streams events to stderr (--json emits structured progress/complete objects).
  • Projects (CloudProjectsController, CloudDeepLink): paginated list/delete/download in the QML My Cloud Projects dialog; qtmesh://cloud/open?owner=…&project=… deep links open the file browser. CLI/MCP parity: cloud list, cloud delete, cloud upload, cloud status, cloud limits.
  • Security: bearer tokens are never logged in Sentry breadcrumbs; manifests must not contain absolute paths/usernames. Upload requires an explicit user action in the GUI; CLI uses --no-confirm for CI. API base override: QTMESH_API_BASE (tests + self-hosted).
  • Limits: qtmesh cloud limits / MCP cloud_limits read server caps from GET /v1/auth/me when exposed; scan reports are capped at 5 MB client-side.

Gamification / Progress Sync (epic #796, cloud epic qtmesh-cloud#79)

  • GamificationManager (src/GamificationManager.h/cpp): singleton orchestrator (also a QML singleton under both WelcomeScreen 1.0 and PropertiesPanel 1.0). Instrumentation is two static one-liners: GamificationManager::noteFeature("<feature_key>", Surface::Gui|Cli|Mcp) at controller entry points (E-P2 #798) and noteOperation("<op>", {{"tris_before", n}, …}) where before/after metrics exist (E-P3 #799). noteOperation also counts the matching feature cluster, so op sites need only one call. Both are thread-safe (marshal to the main thread) and no-ops until the user consents.
  • Privacy invariants (E-P6 #802): default OFF — nothing is queued before the one-time non-blocking consent prompt (shown on first would-be event while signed in) is answered or "Sync my QtMesh progress" is enabled in Preferences → General. Metrics are filtered to numeric values only (numericMetricsOnly) so asset content/file names can never leak. Zero network when logged out or opted out. DELETE /v1/me/gamification + local queue/cache purge via the Preferences "Delete my gamification data" button. CLI honours --no-telemetry.
  • Queue + flush (E-P1 #797): GamificationEventQueue (src/GamificationEventQueue.h/cpp) is a persistent, cross-process-safe (QLockFile) JSON queue in <AppData>/gamification/queue.json; every event carries a client UUID idempotency id (the server dedup key), capacity 500 with logged FIFO eviction. GUI flushes on a debounce + 90s heartbeat + graceful-shutdown, with exponential backoff (max 30 min); CLI enqueues during the subcommand and calls flushBlocking() before _exit. Batches go to POST /v1/events/editor / POST /v1/events/operations (max 200/batch); newly-earned achievements come back in the response.
  • Cloud contract: feature/op keys are [a-z0-9_]+; the 25 discovery cluster keys live in Gamification::featureCatalog() (src/GamificationTypes.h/cpp) and MUST match qtmesh-cloud's DISCOVERY_FEATURES. GET /v1/me/stats parses into Gamification::StatsSnapshot (NB mixed casing on the wire: stats/featureUsage snake_case, progress/achievements camelCase); cached to <AppData>/gamification/stats_cache.json for offline rendering. Milestone progress ("nearest unlockable") is computed client-side from milestoneCatalog() + counters.
  • Status surface (E-P4 #800): level + XP bar, streak and nearest-unlockable progress render in the CloudAccountMenuButton menu (refreshes stats opportunistically on menu open); "View My Achievements…" opens https://qtmesh.dev/u/<slug> (profile is private by default — setProfilePublic PUTs /v1/me/gamification/prefs). Unlocks show ONE coalesced GamificationToast (no animation, auto-hide, click-to-dismiss).
  • Discovery nudges (E-P5 #801): the welcome screen shows at most one dismissible "try this next" card from GamificationManager.suggestion — personalized to unused clusters when stats exist, generic rotation when logged out; rotation cursor + dismissals persist in QSettings (Gamification/* keys, see AppSettingsKeys.h).
  • Ops with metrics instrumented: retopo (GUI+CLI), decimate/LOD (GUI+CLI), optimize (CLI), uv_unwrap (GUI), auto_rig (GUI sync/async/marker + CLI), skin_weights (GUI+CLI), fix (CLI), texture_atlas pack (GUI), isometric_sprites (GUI), vat_bake (GUI), vertex_color_bake (GUI), morph (GUI), motion_inbetween (GUI), pbr_synth (AIAssistManager). MCP tools map to clusters in MCPServer::callTool; CLI subcommands map in CLIPipeline::run.

Contextual feedback (#1058)

Lightweight in-app feedback to explain churn: users who open the editor once and never return. Retention data cannot separate "they got what they came for" from "something didn't work" — those look identical but call for opposite responses — so the prompt is built to distinguish them.

  • FeedbackPromptController (src/FeedbackPromptController.{h,cpp}): decides when to ask. Triggers are FirstExport, ImportFailure, ExportFailure, and SessionNoExport (imported, worked ≥ kSessionNoExportMinutes, never exported — the churn-shaped case). A successful first import deliberately does NOT prompt: that is the start of the workflow, and asking there interrupts someone who has not yet had a chance to succeed or fail. At most one prompt per session; kCooldownDays (14) between prompts; stops entirely after kMaxDismissals (3) consecutive dismissals. Answering resets the dismissal budget.
  • Gated on telemetry consent. The prompt is identified by SentryReporter::anonymousInstallationId(), which only exists when telemetry is on. Without it a response cannot be joined to that install's activation/retention data — which is the only reason to ask. Feedback/promptEnabled (default ON) is a separate opt-out in Preferences.
  • Headless-safe: maybePrompt checks isSignalConnected(promptRequested) before consuming the one-per-session budget, so a CLI/MCP run never burns it (the GamificationManager::maybeRequestConsent pattern).
  • UI: non-modal QMessageBox in MainWindow (the consent-prompt pattern), offering "Got what I needed" / "Something didn't work" / "Not now" — two affirmative answers rather than a thumbs up/down, so a satisfied one-time user is distinguishable from a blocked one. Negative hands off to the existing FeedbackDialog, prefilled from the trigger.
  • Transport: QtMeshCloudClient::submitFeedback now accepts either a bearer token or anonymousInstallationId (qtmesh-cloud#107 added POST /v1/feedback anonymous support, migration 0028, rate-limited per install). authorizedJsonRequest omits the Authorization header entirely when there is no token — a bare Bearer reads as a malformed credential.
  • Both sinks, asymmetric by design. The cloud table is the record of truth (long retention); Sentry is short-term observability. Which outcomes reach which sink: positive → cloud row + Sentry; negative → Sentry, plus a cloud row only if the user completes the detailed dialog; dismissals → Sentry only (someone who declined to answer should not generate a feedback record). So: a negative answer goes through FeedbackDialog with the user's comment, and "Got what I needed" fires a silent message-less rating: great submission (postSilentRating, detached worker since submitFeedback blocks) — without that the table would collect complaints exclusively and could not answer "did they finish?", which is half the churn question. Sentry gets feedback.prompt_shown|dismissed|positive|negative|submitted via captureTelemetryEvent with structured metadata plus message_length — never the message text, since sanitizedValue strips paths/filenames but not prose PII (an email typed into a comment would leak into a different retention and access regime). The words live only in the cloud row, linked by feedback_id.
  • Settings keys live under Feedback/ in AppSettingsKeys.h.

MCP Server

  • MCPServer (src/MCPServer.h/cpp): JSON-RPC 2.0 over stdio + HTTP REST API on configurable port.
  • Runs on main thread via QSocketNotifier. Never use BlockingQueuedConnection (causes deadlock).
  • Launch modes: --mcp (headless), --with-mcp (GUI + MCP).
  • stdout is redirected to stderr to isolate MCP JSON-RPC from Ogre/Qt debug output; original stdout fd saved for MCP responses.
  • HTTP API uses QTcpServer with deferred tool execution (QTimer::singleShot) to avoid re-entrant crashes from Ogre event processing.
  • HTTP API hardening (#984): the REST surface can run EVERY tool, mutating ones included, so (1) it binds loopback by default — --http-bind <addr> / QTMESH_HTTP_BIND opt into 0.0.0.0 (containers with a mapped port), and a non-loopback bind without a token logs a warning; (2) tools execute only via POST /api/tools/<name> — GET /api/tools/<name> answers 405 + Allow: POST and dispatches nothing (it used to run the tool with no arguments, so any link/prefetch could decimate a mesh); GET /api/tools still lists; (3) an optional shared secret — --http-token-file <path> (MCPServer::readHttpTokenFile, trimmed; warns when group/other-readable; an unreadable/blank file means the HTTP server is NOT started — down beats up-and-unprotected), else QTMESH_HTTP_TOKEN, else QSettings mcp/httpToken (MCPServer::resolveHttpToken). --http-token <secret> on argv is refused at startup (exit 2) — review caught that a command-line secret sits in ps//proc/<pid>/cmdline for the whole session, readable by every local user, i.e. useless on exactly the shared machines it targets — when set, every request except the CORS preflight (which cannot carry credentials by spec) must send Authorization: Bearer <t> or X-Api-Key: <t>, else 401 + WWW-Authenticate; the compare is constant-time (httpRequestAuthorized, pure + unit-tested). Default (no token) stays open to local processes, so the scripts/anim-*.sh harnesses (all POST, plus a GET /api/tools liveness probe) run unchanged. AppLaunchHandler::isGuiModeValueFlag skips the --http-port/--http-token(-file)/--http-bind VALUES in BOTH collectGuiLaunchPaths (a token file never becomes a launch path) AND isCliInvocation (a value equal to a subcommand name or --cli — --http-token-file scan — must not reroute the whole launch into CLIPipeline::run; review finding) — and that skip is now checked BEFORE the generic leading-- continue in collectGuiLaunchPaths; it used to sit after it, i.e. was dead code, and --http-port 8080 only appeared to work because "8080" is not an importable file (the test uses a real .obj as the value so the skip is observable).
  • take_screenshot captures via an Ogre RTT, NOT QWidget::grab() — Ogre renders straight to the native window surface (WA_PaintOnScreen) so grab() returns a black buffer. The tool renders the active viewport's SpaceCamera camera into an offscreen PF_BYTE_RGBA render target (RTSS MSN_SHADERGEN scheme + a temporary ambient boost & directional key light so imported materials aren't black, restored after) and reads it back to PNG. load_mesh calls frameSceneInActiveViewport() (select every user node → frameSelection()) so a headless load_mesh→take_screenshot actually frames + shows the mesh. This makes autonomous visual QA (load → optionally explode via transform_submesh → screenshot) work without a GUI operator.

CLI Pipeline

  • CLIPipeline (src/CLIPipeline.h/cpp): Headless command-line interface for mesh operations. All static methods — entry point is CLIPipeline::run(argc, argv).
  • Subcommands: info, fix, convert, anim (list/rename/merge), validate, lod, pose, turntable, isometric, scan, material, hdri, pack-textures, normal-from-height, memory, analyze, vertex-cache, decimate, atlas, atlas-apply, optimize, cloud (login/logout/status/limits/list/upload/delete).
  • Activated via qtmesh symlink (created at build time), --cli flag, or recognized subcommand as first arg.
  • Redirects stdout to stderr (Ogre/Qt noise) and writes CLI output to the original stdout fd. Uses _exit() to avoid Ogre static destructor crashes on macOS.
  • AnimationMerger (src/AnimationMerger.h/cpp): Public renameAnimation() static method used by both CLI and GUI for animation renaming.
  • ScanEngine (src/ScanEngine.h/cpp): Directory scanner for 3D asset linting. Loads every asset through MeshImporterExporter (the editor's own loader) and walks the resulting Ogre scene with CLIPipeline::extractMeshInfo — the same extractor MeshInfoOverlay uses, so the scan, the CLI info subcommand and the in-app overlay all report identical counts for the same asset. Redundant-keyframe analysis (and the --fix write-back since slice C4) goes through AnimationMerger::analyzeRedundantKeyframes / simplifyAnimation, the same code path as qtmesh anim --simplify and the Inspector "Simplify" button. The fix path re-exports via MeshImporterExporter::exporter for every supported format (FBX/glTF/glb/DAE/OBJ/PLY/STL/.mesh) — no Assimp::Exporter. ACMR is folded into the same Ogre walk so each file is loaded once per scan. Assimp's only remaining role is a no-process ReadFile to enumerate aiMaterial::GetTexture references that Ogre's TUS-name walk wouldn't see when a referenced texture file is missing on disk (needed for require_textures_exist). Quality rules driven by the Ogre walk: max_texture_resolution (largest texture dimension cap), require_uv_channels (per-submesh UV-set minimum), detect_zero_weight_bones (Mixamo bloat — bones with no vertex weights), detect_overlapping_uvs_pct (UV0 AABB sweep — lightmap quality), detect_non_manifold_edges_pct (edges shared by != 2 faces — boolean / printing safety). Enumerates files via glob patterns, evaluates configurable rules, produces text/JSON/SARIF reports. Per-file cleanup happens in clearOgreSceneForScanImport which destroys scene nodes and flushes MeshManager / SkeletonManager so a 1000-asset scan doesn't accumulate state.
  • ScanConfig (src/ScanConfig.h/cpp): Config loader for qtmesh.yml/.json. Includes a minimal YAML parser for the specific config schema (scalars, inline/block lists, one level of section nesting). Supports scan paths, rule configuration, fix behavior, and report output settings.

Performance capture (epic #869, ENABLE_MOCAP)

Video/webcam -> facial morph + head + skeletal body animation, built on the existing ONNX/model-download/retarget/morph-keyframe infrastructure. Everything lives in src/Mocap/; the CMake flag ENABLE_MOCAP (default OFF; ON for macOS/Linux release + the Linux test lane) requires ENABLE_ONNX and pulls in Qt6::Multimedia (a new dependency — Windows/MinGW pending verification, so OFF there). Non-mocap builds print "rebuild with -DENABLE_MOCAP" on every surface.

  • VideoFrameSource (src/Mocap/VideoFrameSource.{h,cpp}): the frame abstraction — FileFrameSource (QMediaPlayer+QVideoSink, targetFps decimation, playback-driven), CameraFrameSource (QCamera, device enumeration, latest-wins FrameMailbox so live inference never falls behind), ImageSequenceFrameSource (headless test double / CLI --frames-dir). FrameDecimator/FrameMailbox are pure + tested.
  • FaceCapPredictor (ONNX consumer #9): 3 sessions (BlazeFace detector 128² [-1,1] -> Face Mesh V2 rotated 256² crop [0,1] -> 478 landmarks -> 146-subset px coords -> 52 blendshape scores) with detector-skip tracking (next ROI from previous landmarks). PoseCapPredictor (consumer #10): BlazePose detector+landmarks -> 33 world landmarks; tracking from the model's aux alignment landmarks. Both download to AppData/ai_models/mocap/{face,pose}/ (QTMESH_MOCAP_MODEL_BASE_URL / ai/mocapModelBaseUrl / QTMESH_MOCAP_NO_DOWNLOAD). The exact pre/post-processing contract (anchors, letterbox, weighted NMS, cv2-integer-index plain bilinear — never antialiased, projection) was parity-proven in the Slice A spike: docs/MOCAP_SPIKE.md, scripts/export-facecap-onnx.py (converts AND asserts parity ≤0.6px/≤0.015 blendshape/≤1cm world).
  • Pure-data core (all headless-tested): FaceCapGeom (letterbox/anchors/decode/NMS/ROI/crop/projection), FaceCapPose (Horn quaternion weighted rigid fit + embedded canonical face model — no Eigen), FaceCapMapper (ARKit-52 -> mesh target names, side-suffix normalization + alias table + JSON override; unmatched ALWAYS reported), OneEuroFilter (scalar + hemisphere-aligned quat), PoseIKSolver (33 world landmarks -> 22 canonical WORLD quats; torso basis + parallel-transported limb twist reference — continuous, no candy-wrap), FaceCapCanonicalData.h (generated constants).
  • MocapRecorder (the Ogre-touching piece): recordFace writes weight keys via MorphAnimationManager::writeWeightKeyOn (the entity-explicit static the #519 path now delegates to) with epsilon suppression + gap hold-keys; head deltas (neutral = first confident frame) land on the Head bone (<clip>_Head on the MESH skeleton) or node TRS. recordBody feeds [frame][22] world quats into AnimationMerger::applyMotionClip(worldFrame=true) — the #411 retarget, no fork. RecordMocapClipCommand/RecordBodyClipCommand make a take ONE undo step (keyframe-level snapshots).
  • MocapController (QML_SINGLETON, PropertiesPanel 1.0): live mode — camera -> mailbox -> inference worker thread -> queued samples -> main-thread live drive (morph setWeight + manual Head bone) with EXACT snapshot/restore (weights, bone state, enabled AnimationStates) around the preview session. Panel: qml/PropertiesPanel.qml "Performance Capture" section (Animation-mode Mode Tools). SAM 3D Body is the declared body quality path but its checkpoints are HF-gated — dispatch falls back to pose-ik with algorithmUsed/fallbackReason until the export is hosted (decision record in THIRD_PARTY_AI_MODELS.md).
  • Surfaces: CLI qtmesh mocap (src/Mocap/MocapCLI.cpp, the SceneLightsCLI pattern); MCP capture_face_from_video/capture_body_from_video (heavy) + list_capture_devices/start_live_capture/stop_live_capture (GUI-attached). Sentry ai.assist.mocap_face|mocap_body|mocap_live; gamification cluster mocap (cloud-side DISCOVERY_FEATURES coordination required). Hosting: scripts/upload-mocap-models.sh -> HF models repo mocap/{face,pose}/ (+ Apache NOTICE). User guide: docs/MOCAP.md.

PS1 formats (static) and runtime extraction (experimental)

  • Static parsers (src/PS1/): PS1TMD, PS1TIM, PS1RSD, PS1PLY, PS1MAT for known PlayStation mesh/texture formats.
  • Runtime extraction (src/PS1/runtime/, epic #412): ENABLE_PS1_RIP (OFF by default). When ON, PS1RipManager runs an EmuCore host from <app>/PS1Cores/ on a worker thread: prefer qtmesh_ps1core_libretro (loads beetle_psx_qtmesh_libretro (rip fork, tried first) / mednafen_psx_libretro / beetle from PS1Cores/, system libretro paths, or QTMESH_PS1_LIBRETRO_CORE) for real ISO playback; fall back to qtmesh_ps1core_stub for CI. Live VRAM + RAM GP0 scan feed phases 2–3 when using libretro. Install helper: scripts/install-ps1-libretro-core.sh. Session UI: Tools → Experimental → PS1 Runtime Ripper… (PS1RipSessionWindow, EmuViewport). Design doc: src/PS1/PS1_RIP_DESIGN.md. CI enables the flag on Linux test builds only. Sentry breadcrumbs use category ps1.rip.
  • In-core rip capture (#813–#817 — the path that extracts real models from retail games, incl. custom engines): a vendored fork of beetle-psx-libretro (fernandotonon/beetle-psx-libretro, branch qtmesh-rip, artifact beetle_psx_qtmesh_libretro.*, all changes behind HAVE_QTMESH_RIP) exposes a versioned C ABI (rip/qtmesh_rip_abi.h, vendored byte-identical at src/PS1/runtime/libretro/qtmesh_rip_abi.h). The fork records every GTE RTPS/RTPT transform (object-space vertex + exact rotation matrix + precise outputs) into a 65536-entry ring and rides PGXP's value tracking (PGXP_value::rip_tag: kept on pure moves, dropped on any recompute/splice) so each GP0 vertex word arrives with per-vertex provenance (qtmesh_rip_vertex_shadow: PGXP precise x/y, view depth, GTE record index). Host: LibretroHost optionally resolves the 3 qtmesh_rip_* symbols; LibretroEmuCore registers trampolines (ABI-version-checked, refused on mismatch), mirrors the armed flag into the core each frame, and unregisters before unload; RipperHooks buffers tracked draws until the frame's GTE record flush and resolves per-vertex provenance tiers (GteTracked/DepthOnly/None, with a record-vs-shadow coordinate backstop). While the in-core stream is active the heuristic RAM GP0/GTE passes are suppressed (TMD/HMD model-space scanners stay on); attribution gp0_incore outranks gp0_hook; in-core cap 16384 prims/frame with overflow breadcrumb. MeshReconstructor consumes the tiers (#816): tracked verts land exact model-space coords, prims group on real record matrices, instances carry rot/trWorld/hasMatrix. Build the fork via scripts/build-ps1-rip-core.sh (pinned commit) or -DENABLE_PS1_RIP_CORE_BUILD=ON; QTMESH_PS1_RIP_INCORE=0 A/Bs back to RAM heuristics. Status bar: in-core hooks: active + tracked N% · depth M%. Zero-ROM CI conformance: tests/fake_rip_core/ + InCoreRipCapture_test.cpp drive the full chain through the real plugin trampolines. Golden metric bars incl. the custom-engine retail-c scene: src/PS1/golden_captures.md.
  • Capture cleanup (#428): Ps1NormalizerSettings opt-in toggles applied during reconstruction — cleanupWeldNormals (weld coincident verts + smooth normals), cleanupRemoveZeroArea (drop collinear/duplicate-vertex sliver triangles via MeshReconstructor::applyZeroAreaTriangleCull), plus the existing spikeEdgeFactor degenerate-span cull. Surfaced as the ripper toolbar "Smooth" / "Drop slivers" checkboxes, MCP ps1rip_capture smooth/remove_zero_area, CLI --smooth/--drop-slivers; ps1.rip.cleanup.* breadcrumbs. ScanEngine flags dirty captures via ps1-rip-zero-area (Warning) + ps1-rip-degenerate-uv (Info) rules. T-junction resolution from the issue is deferred (weld covers the common coincident-vertex case).
  • Same-object cross-frame merge (#412): Ps1NormalizerSettings::mergeSameObjectParts (ON by default — the raw per-frame stream is hundreds of sparse fragments of a handful of objects, so merging is what makes a scene capture usable; opt out via GUI checkbox / CLI --no-merge-objects / MCP merge_objects:false). Scene captures group prims by the exact per-frame GTE matrix hash, so a moving object (or moving camera) yields one sparse part per frame — games NCLIP-cull back faces before GP0, so each frame only carries the camera-facing triangles. The merge (MeshReconstructor::mergeSameObjectGroups, pass 1.5 before bucket seeding) clusters tracked groups by EXACT object-space vertex-set overlap (packed s16 GTE V registers — bit-identical across frames for rigid objects; greedy incremental union in capture order, ≥8 shared verts AND ≥25% of the group's set) and remaps them onto the first-seen group's key, so the triangle union reconstructs the full object incl. faces no single frame showed. Groups sharing a FRAME never merge (simultaneous instances / hierarchy limbs stay separate — preserves instancing + per-copy matrices). A duplicate-triangle cull (canonical position+UV key, colour deliberately excluded — gouraud re-lights per frame; winding-preserving corner rotation) drops once-per-frame repeats, also collapsing static-object repeats that always landed in one group. Stage 2 (non-rigid): vertex-ANIMATED objects (CPU/GTE-warped menu text, breathing characters) defeat exact matching — their per-frame clusters are chained by deformation-surviving signals (texture-key Jaccard ≥0.5, per-frame prim-count ratio ≥0.4, frame gap ≤3, and coarse 16-unit spatial-cell overlap ≥0.25 OR same draw-order rank ±1 — PS1 engines submit objects in a stable order) and each chain keeps its BEST member (most prims, then earliest) while the other frames' prims are DROPPED (a union would superimpose every warp phase into ghost soup). Only short-span clusters (≤2 frames) may chain — a rigid object always stage-1-merges into one long-span cluster, so the span gate keeps stage 2 from chaining two different long-lived props and dropping real geometry. Stats: mergedPartGroups (rigid) + nonRigidMergedGroups. Tier-1 verts still invert against their own prim's tracked matrix (per-frame), so the merge never mixes camera spaces; the merged part's instance keeps the first frame's placement matrix. RAM-scan (legacy matrixId) groups have no object-space identity and are untouched. Surfaced as the ripper toolbar "Merge frames" checkbox (default checked), MCP ps1rip_capture merge_objects (default true), CLI --no-merge-objects opt-out (--merge-objects kept as a back-compat no-op); breadcrumb ps1.rip.cleanup.merge_objects (mergedGroups + nonRigidGroups + duplicateTrisDropped, also in MeshReconstructionStats). Unit tests: MeshReconstructorMerge_test.cpp.
  • Rigid animation capture (#429): Ps1AnimationExtractor (Ogre-free, unit-tested) groups a scene capture's per-frame gteRecords into one matrix track per moving object (identity = seq-contiguous same-matrix run → object-space vertex-set hash; keys ordered by frame). PS1RipMeshBuilder::authorRigidAnimation matches each track to its capture node by first-frame world translation and authors an Ogre::NodeAnimationTrack via NodeAnimationManager so the ripped motion plays in the viewport. In-editor preview only — node-transform tracks don't yet round-trip through the glTF/FBX exporter (skeletal only). Opt-in via Ps1NormalizerSettings::captureRigidAnimation (ripper "Rigid anim" toggle / MCP rigid_animation / CLI --rigid-animation); scene capture only; best on a static-camera scene (GTE matrices are View×Model). ps1.rip.anim breadcrumb.
  • Headless CLI (#431): qtmesh ps1 capture|dump-vram (CLIPipeline::cmdPs1) drives PS1RipManager on its worker thread, pumping the Qt event loop (no exec()), then exports via MeshImporterExporter::sceneExporter. JSON input scripts ([{frame,button}, ...]) + --auto-input for reproducible/unattended captures; initOgreHeadless() so the reconstructor has a scene; Xvfb on Linux. MCP parity was already the ps1rip_* tool set.

Mesh Import/Export

  • MeshImporterExporter (src/MeshImporterExporter.h/cpp): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors in src/Assimp/. Also provides sceneExporter()/sceneImporter() for saving/loading entire scenes (multiple entities with transforms, materials, skeletons, and animations) as glTF files. Multi-entity scenes use entity-name-prefixed bones to avoid cross-entity skeleton contamination when Assimp merges skins. Auto-scales sub-unit meshes: assets with bounding-box max-extent below 0.01 (mm-scale FBX, photogrammetry, etc.) get their parent SceneNode scaled by 1/maxExtent so the largest dim lands at ~1 unit — without this they sit inside the camera near-clip plane and never render. configureCamera() reads getWorldBoundingBox(derive=true) so the camera distance accounts for the auto-scale.
  • MeshDracoEncoder (src/MeshDracoEncoder.h/cpp, issue #506): standalone Draco mesh-compression post-processor for glTF/glb export (qtmesh convert -o out.glb --compress draco, MCP/GUI parity is a follow-up). Why standalone ("Path B"): Assimp's glTF2 exporter has ZERO Draco support — ASSIMP_BUILD_DRACO=ON only wires the Draco decoder into the glTF2 importer (verified against the Assimp 6.0.5 source: glTF2Exporter.cpp has no draco references; glTF2Asset.inl guards the decoder under ASSIMP_ENABLE_DRACO). So there is no Assimp export flag/property that emits compressed output; compression must be applied to the finished file. The module is Ogre-free + unit-tested (MeshDracoEncoder_test.cpp): it parses the written glTF (glb BIN chunk, or .gltf + data-URI/external .bin) with QJsonDocument, and for each indexed-triangle primitive with POSITION it reads the geometry accessors, builds a draco::TriangleSoupMeshBuilder. Compression is ALL-OR-NOTHING per primitive: Draco reorders + deduplicates points, so any per-vertex stream NOT folded into the Draco buffer would be left in the original vertex order and then addressed by the reordered Draco indices — silently corrupting it. So a primitive is compressed ONLY if every attribute can go into Draco losslessly; if it carries JOINTS_n/WEIGHTS_n (skinning must stay bit-exact), a non-FLOAT attribute (e.g. normalized-integer COLOR_0), or morph targets (primitive.targets), the whole primitive is left uncompressed. Compressible attrs (POSITION/NORMAL/TEXCOORD_n/COLOR_n float/TANGENT) are encoded via draco::Encoder (per-attribute quantization: pos 14 / normal 10 / uv 12 / colour 8 bits); every index is bounds-checked against its attribute's element count first. The Draco blob is appended as a new bufferView; the KHR_draco_mesh_compression extension is added to the primitive ({bufferView, attributes:{SEMANTIC: dracoUniqueId}}); the compressed accessors get bufferView/byteOffset stripped AND their count rewritten to the Draco decoded point count (Draco may merge duplicate points — a stale count fails validators); the extension is added to extensionsUsed + extensionsRequired. Orphaned geometry bufferViews are then garbage-collected (via a generic recursive scan of the whole JSON for bufferView refs, so unknown consumers like EXT_meshopt / structural-metadata / vendor extensions are preserved), and the binary buffer is rebuilt (all reads bounds-checked). Skins/morph targets/animations/IBM accessors are byte-for-byte untouched. Output re-serializes as glb (4-byte-aligned JSON+BIN chunks) or self-contained .gltf (base64 data-URI buffer), written atomically (temp file + verified byte count + rename) so a failed compression never truncates the already-written uncompressed export. Everything is #ifdef ENABLE_DRACO-guarded; a build without Draco returns a clear "rebuild with -DENABLE_DRACO" error (the CLI validates --compress up front). Build: -DENABLE_DRACO=ON + Draco present — CI builds the vendored contrib/draco as a standalone static lib and installs it beside Assimp (auto-discovered from assimp_DIR prefix by cmake/Draco.cmake); locally use -DDRACO_ROOT=<dir>. Default OFF; enabled on the Linux + macOS CI builds (Windows/MinGW off, like ONNX/mocap). Known limitation: skinned meshes (every Mixamo character) carry JOINTS_n/WEIGHTS_n on every primitive, so nothing is eligible and the file is left uncompressed — the CLI treats "0 primitives eligible" as a NON-fatal warning (exit 0, valid uncompressed file kept), not an error. Compressing skinned/morph geometry (folding JOINTS/WEIGHTS into the Draco stream losslessly) is a tracked follow-up.
  • FBXExporter (src/FBX/FBXExporter.h/cpp): Custom FBX Binary v7300 exporter that writes directly from Ogre data. Handles geometry, skeleton, skin deformers, animations, and materials. Replaces Assimp's broken FBX exporter. PBR slot dispatch (slice F4/F5): albedo connects under both Maya|TEX_color_map (which Assimp routes to aiTextureType_BASE_COLOR) AND DiffuseColor (legacy aiTextureType_DIFFUSE) so reimport recreates the diffuse_map slot and matches first-import slot ordering. Metallic/roughness/ao/emissive use the Maya Stingray PBS prefix (Maya|TEX_metallic_map, Maya|TEX_roughness_map, Maya|TEX_ao_map, Maya|TEX_emissive_map) — the only PBR property naming Assimp's FBXConverter::SetTextureProperties recognises and translates to the matching aiTextureType. Normal map keeps the standard NormalMap property. Material Properties70 writes shininess as ShininessExponent (Assimp reads AI_MATKEY_SHININESS only from that name; the legacy Shininess is silently dropped on reimport). Texture payloads are embedded via Video.Content from one of three sources, tried in order: (1) the import-time EmbeddedTextureCache (textures Assimp extracted from inline FBX Video.Content — Boss_normal.png inside Rumba Dancing.fbx is the canonical case), (2) Ogre's resource-group file index (textures discoverable from a registered FileSystem location at index-build time), and (3) a direct filesystem probe of every registered resource location (textures that landed on disk after the index was built). Without (1) the round-trip of FBX-with-embedded-textures dropped every payload that wasn't sitting on disk next to the output — issue #508.
  • EmbeddedTextureCache (src/EmbeddedTextureCache.h/cpp): process-wide thread-safe store for raw texture bytes extracted from aiScene::GetEmbeddedTexture. MaterialProcessor::loadTexture stashes (the std::byte* overload keeps the reinterpret_cast at one site); fbxResourceBytes::read inside FBXExporter.cpp retrieves first, before any resource-group or filesystem lookup. Pure-data — no Ogre dependency. The cache lives for the process lifetime; call clear() after a finished import session (i.e. once every MaterialProcessor::loadTexture for that asset and every Video.Content-writing exporter pass have run) or before starting an unrelated import session, to release retained bytes back to the OS. Most workflows can ignore it — only call clear() when memory pressure from cached textures is a real concern (large batch imports, long-running editor sessions).
  • MaterialProcessor (src/Assimp/MaterialProcessor.h/cpp): Builds Ogre::Material from Assimp aiMaterial. Reads legacy aiTextureType_DIFFUSE / _NORMALS / _HEIGHT / _NORMAL_CAMERA plus PBR types (_BASE_COLOR, _METALNESS, _DIFFUSE_ROUGHNESS with _SHININESS fallback, _AMBIENT_OCCLUSION, _EMISSIVE and _EMISSION_COLOR for Maya-Stingray-styled FBX) and binds them to the slice E canonical PBR slot names (albedo, metallic, roughness, ao, emissive) so PBR-aware tooling sees populated slots even on FBX/glTF imports. Slot order matches the typical third-party PBR FBX layout: [diffuse_map, metallic, roughness, ao, emissive, albedo] — albedo is always last (either via aiTextureType_BASE_COLOR or via the legacy DIFFUSE alias fallback). When no BASE_COLOR is exposed but a legacy aiTextureType_DIFFUSE was, the importer aliases the diffuse texture under albedo (non-FFP). When albedo IS exposed but pass->diffuse is essentially black (PBR exporters write (0,0,0)), it is forced to white so the FFP modulate doesn't crush the texture to near-black. Calls RTShaderHelper::wirePbrSlotsForFFP + material->compile() at end of import so freshly-imported materials render the same as they would after a no-op Apply in the Material Editor (without this, imported PBR FBXes were noticeably darker on first render). In-session re-imports (existing-material branch) merge in any missing PBR slots without replacing existing TUS, then re-wire FFP. Pass is not tagged pbr_workflow on import — that would auto-promote to SRS_COOK_TORRANCE_LIGHTING via the applyNormalMap redirect, producing dark output without IBL. A future slice may expose a "Convert to PBR" inspector action that adds the tag deliberately when IBL is in place.

Local LLM

  • LLMManager (src/LLMManager.h/cpp): QML_SINGLETON wrapping llama.cpp for local inference.
  • LLMWorker: Runs inference in a worker thread.
  • ModelDownloader: Downloads GGUF/ONNX models from HuggingFace — the ONE download path behind all 21 model consumers. #1029 (CWE-494) hardening: startDownload refuses any URL that is not https://<host> or a host-less file:// (plain http is a byte-for-byte MITM injection point, and every base URL is user-overridable via env/QSettings; file://<host>/share/… is refused too — on Windows that is a UNC/SMB fetch over the network wearing a local scheme; only an empty or localhost host is accepted, and the error names the reason) — refused BEFORE any filesystem side effect, so no .part or directory is created. An optional 4th argument expectedSha256 (case-insensitive hex; empty = legacy no-check, so the 21 consumers + the two QML call sites in AISettingsDialog.qml work unchanged) is verified by a local streamed QCryptographicHash helper on the finished .part on disk, before the rename (deliberately NOT the updater's UpdateVerifier::sha256HexOfFile: qtmesh_updater is only built/linked under ENABLE_AUTO_UPDATER, while ModelDownloader compiles unconditionally — reusing it broke the -DENABLE_AUTO_UPDATER=OFF link, caught in review; six lines of Qt API beat coupling every model download to the optional updater + libsodium) — never as a running hash in onReadyRead, because a resumed download appends to bytes this process never saw. On mismatch the .part is deleted (a poisoned partial must not be resumed from or cached) and downloadError fires. Two gotchas verified empirically: (1) the scheme refusal is emitted QUEUED (QMetaObject::invokeMethod + Qt::QueuedConnection), never synchronously — consumers connect, call startDownload, then loop.exec(), so a synchronous loop.quit() fires before exec() and is lost, hanging them for their full timeout (the #1017 review race); queued delivery lands inside exec() for every consumer without touching any — proven on PhotoDepth, which has no settled guard, failing in 1s instead of 600s. (2) Both refusals log at qCritical, not qWarning: cliMessageHandler drops warnings unless --verbose, and every consumer discards downloadError's text (#1037), so a warning would leave the user with "offline?" and no way to learn the cause. Resume verification (#1036): a resume sends Range: bytes=N-, but a server that ignores it (file:// always; any proxy that strips the header) answers 200 with the whole body, and appending that after the stale .part produced a corrupt model — reproduced: a 29-byte stale prefix yielded a 208,044,845-byte file that still loaded and ran (ORT parsed the garbage as an unknown protobuf field) while a 30-byte prefix failed with "Protobuf parsing failed"; load success proves nothing about integrity. So on the FIRST readyRead of a resumed request (m_resumeUnverified, armed at both Range-request sites) the downloader requires 206 + a Content-Range starting exactly at the resume offset; anything else is treated as the full body — the .part is reopened Truncate, m_resumeOffset/m_bytesReceived reset to 0 (else progress adds a phantom offset), and the download continues from byte 0 with no error (a 200 is recoverable). The range unit is compared case-insensitively (RFC 9110 §14.1 — Bytes 9-12/13 is a valid honoured resume; a case-sensitive match misread it as "ignored" and fell into the truncate path with a PARTIAL body, i.e. an incomplete file a no-digest caller would rename — caught in review). A 206 whose window is neither ours nor the whole resource is a genuinely partial body we did not ask for: writing it from byte 0 would yield an INCOMPLETE file, so that case aborts with downloadError and removes the .part (next attempt starts clean); only a 206 covering exactly 0..total-1 — a full body wearing a partial status — is truncated-and-taken like a 200. Checked once, not per chunk. FakeNetworkReply in the tests defaults to a plain 200 (what an ignoring server returns) and withPartialContent(first,last,total) models an honoured resume. Both guards mutation-verified with correct selectivity. ModelDownloader::isAllowedDownloadUrl(url) is the pure predicate for checking a base URL up front. Tests: ModelDownloader_test.cpp (both guards mutation-verified). Review round on the resume fix (#1039): (1) honoured now also requires the window to REACH THE END — first == offset && last == total-1 — because bytes 9-10/13 starts right yet leaves 11-12 missing, and appending it would have promoted an incomplete file (that finding had been marked addressed by the bot without the code changing; verify against the code, not the bot's annotation); (2) every 'this partial is unusable' exit goes through ONE path, discardPartialAndFail — remove-or-truncate the .part, reset m_bytesReceived/m_resumeOffset (a stale offset would make resumeDownload re-request the old range against a fresh file), abort the reply under m_abortingInternally so the synchronously-delivered errorOccurred/finished step aside, end the download, emit exactly ONE downloadError (the test's FakeNetworkReply::signalOnAbort models the synchronous delivery); (3) onDownloadFinished refuses to promote when m_resumeUnverified is still set (a resume reply that finished without ever delivering data — verification never ran) or when the .part size differs from the size the response committed to (m_expectedTotalBytes: the 206 total, else a full body's Content-Length) — the .part is KEPT as a valid prefix for the next resume, not discarded like a digest mismatch. A server that declares no size, with no digest configured, is accepted with a qWarning (chunked transfer; HF/GitHub and QNAM's file:// backend always send Content-Length, so real downloads take the strict path).
  • AI agent harness (src/AIAgentManager.{h,cpp}, src/AIAgentTypes.{h,cpp}, src/AICapabilityRegistry.{h,cpp}, #1000/#1001/#1002/#1003 + #1021 a–d): the orchestration layer above AIChatManager/LLMManager — User → AIAgentManager → Planner → Capability router → Executor → Observer → Replan/Finish. All task state lives in AIAgent::Plan/Step/Observation, never in the prompt; the prompt is rebuilt from that state on every planner call, so nothing scrolls out of a history window and the whole state machine (Planning → Executing → Observing → Replanning/AwaitingConfirmation → Completed/Failed/Cancelled) is driven headless in AIAgentManager_test.cpp by a scripted AgentPlannerBackend + AgentToolExecutor (no LLM, no Ogre). Capabilities, not a catalog: AICapabilityRegistry groups the ~170 MCP tools into 20 capabilities (scene, scene_io, view, materials, lighting, textures_ai, mesh_optimize, uv, rigging, segmentation, generation_3d, animation, motion_ai, morph_pose, node_animation, paint, mocap, cloud, ps1, other — taxonomy() + prefix rules; an unmapped tool lands in other, never dropped). The planner sees a ~20-line capability index plus the FULL per-tool docs only for the capabilities routeByKeywords() pre-selected; it may answer {"need_capabilities":[...]} to have more docs added before planning (dynamic discovery — the v1 loop's hard-coded 19-tool subset silently excluded rigging/segmentation/generation). Tool docs are generated from the live buildToolsList() schema (name, one-line description, params with type/required/enum), so they cannot drift. Constrained protocol (#1003): validateArguments checks every planned call against the schema BEFORE it reaches the server — required present, types checked, chatty-model output coerced ("2"→2, "yes"→true, "2, 2, 2"→array) with warnings, enums enforced; an invalid call becomes an invalid_arguments observation and a replan, never a tool call. Observations (observationFromToolResult) parse facts out of tool text (Vertices: N, JSON boneCount, fallbackReason→warning), collect file artifacts, keep raw for the transcript only — the replan prompt gets one compact line per observation. Recovery: a failing step is retried once (same call), then the planner is asked to repair the tail ({"steps":[...]} or {"done":true,"summary"}), bounded by Limits (12 steps, 2 replans, 2 planner retries); the same Step::signature() failing twice stops the task ("stuck, not unlucky"). One undo group per task (QUndoStack::beginMacro(plan.title) on the first non-read-only step, endMacro on every terminal path — isReadOnly() = get_/list_/toggle_*/screenshots/validators). Safety rail (#1021d): destructiveReason() names deletes/geometry rewrites, overwrites of an EXISTING file, and OUTBOUND/account actions (cloud_upload — the data leaves the machine —, cloud_login, cloud_logout; review finding: nothing local is destroyed, so the delete/overwrite rules let the upload through); unless trustedMode (QSettings ai/agentTrustedMode) the task pauses in AwaitingConfirmation and the panel shows Allow / Always allow / Skip step. Scene context per turn (#1021c): setContextProvider — AIChatManager::sceneSummaryForAgent() (scene info + user materials + recent files) is appended to every planner prompt. The final message is the deterministic summarize() (steps, statuses, parsed facts, artifacts, warnings), so a flaky model cannot misreport what happened; a {"summary"} reply with no steps answers a question without tools. Facade: AIChatManager::agentMode (QSettings ai/agentMode, default ON) routes sendMessage to AIAgentManager::startTask; while the agent drives, the v1 slots ignore LLMManager callbacks (m_agentDriving) and LlmPlannerBackend forwards generation signals only while it has a pending request. QML: qml/AIChatPanel.qml gained the agent/ask-trusted toggles, a live plan card, the confirmation bar, and a model tip (#1021e: Qwen 2.5 7B Q4_K_M is the recommended tool-calling model — its ModelInfo description says so). Sentry ai.agent.plan|step|retry|replan|verify|confirm|done|fail|cancel. Field findings from the first real sessions (Qwen3 4B): (1) ~25 MCP tools act on the CURRENT SELECTION (auto_rig, compute_skin_weights, validate_mesh, generate_lods, auto_uv_unwrap, retopologize, remove_skeleton, …) and the agent had no way to set it — every rig request died with "No mesh selected"; there is now a select_entity {name} MCP tool (node or entity name; empty clears), get_scene_info ends with a Selected: … line, auto_rig's error names the fix, and planner rule 6 says to select first. (2) Conversation memory: the agent keeps the last 12 turns (request → outcome first line [objects touched]) and injects the last 6 plus an "objects from earlier turns" list into every planner prompt, so "now make it red" resolves against the previous task; AIChatManager::clearHistory clears it. (3) Colour NAMES where an [R,G,B] array is expected ("diffuse": "red") are coerced with a warning; rule 7 spells out the create_material → apply_material recipe. (4) Argument aliases: the model wrote material_name and the validator rejected apply_material twice for a missing material although the MCP handler itself accepts material_name — the validator must never be stricter than the tool, so normaliseAliases maps camelCase and a small synonym table (material_name→material, entity/entity_name/node→mesh, output/path→output_path, skeleton→template, …) onto the schema's names BEFORE the required check, and a rejection lists the keys that were passed; the replan prompt shows the failing call's arguments and says "do not resend it unchanged". (5) Chat dock focus: clicking the QML input after another dock (a QQuickWidget) held focus re-focused the QML item but not the hosting widget, so keystrokes went elsewhere until a detour via the viewport; ClickFocusFilter (installed on the chat QQuickWidget) turns every press into setFocus. (6) Context window: switching to another model produced "Failed to decode prompt" — LLMWorker's prompt-too-long pre-check compared against the configured context size while the context actually created is clamped to the model's training limit, so an oversized prompt reached llama_decode. The worker now checks against llama_n_ctx (with a clear message naming both numbers) and emits contextReady(nCtx) → LLMManager::effectiveContextSize; the agent budgets every planner prompt against it (systemPromptWithinBudget: drop history → keep only the most relevant capabilities → truncate the scene listing → hard cut, each step traced), and the default contextSize is 8192 (was 4096 — the tool docs alone need more). Changing the context size needs a model reload. (7) Mesh fed to the image tool: the 14B planner passed an .obj as image_path to generate_mesh_from_image; the schema now says "2D IMAGE … NOT a 3D mesh: use load_mesh", the handler refuses mesh extensions with the right tool named, the capability index says "not for existing meshes", and rejectMeshAsImage in the validator stops any image-typed parameter (image_path/image/photo/texture…) carrying a mesh path before the tool runs. (8) LLM tab parity: LLMManager::deleteModelFile(fileName) / deleteAllModelFiles() (models-directory only — paths are refused; unloads the active model first; removes the .part too) behind per-row Delete / Remove All / Open Folder in AI Model Settings, mirroring the QtMeshEditor Models tab; the chat header's model chip opens that dialog (AIChatManager::openModelSettings → MainWindow::showAIModelSettings). (10) Invented image paths: asked to "create a f22 raptor scene", the planner called generate_mesh_from_image with ~/Downloads/f22_raptor.png (a file that never existed), the tool answered "image not found", and every repair round guessed another path (.jpg, .png again) — six failures, nothing made. The harness now repairs this itself BEFORE the call (AIAgentManager::repairMissingImageInput, in executeNext after coercion): a non-existent image_path is dropped and, when no prompt was given, the request's subject (subjectFromGoal: leading creation verbs/articles and a trailing "scene"/"model" stripped — "create a f22 raptor scene" → "f22 raptor") becomes the tool's text prompt (text → image → 3D), with a transcript note. Planner rule 8 says never to invent paths and when to use prompt; the replan prompt adds an explicit "that file does not exist — do NOT guess another path" hint whenever a step failed with not found / does not exist / no such file; the schema text for image_path says the same. Text → image still needs a stable-diffusion build + FLUX.2-klein (AI Model Settings) — without them the ONE remaining call fails with that actionable message instead of a path hunt. (11) A heavy tool froze the whole UI. The agent drives tools SYNCHRONOUSLY on the main thread, so generate_mesh_from_image (minutes of ONNX work) blocked the event loop: the window stopped painting and looked hung. The heavy work CANNOT simply move to a worker — MeshGenBuilder::buildSceneNode is Ogre and main-thread-only — so instead MCPServer gained a toolProgress(tool, stage, done, total) signal that the generation tool drives from MeshGenPredictor::predict's existing ProgressFn (it already fires many times per stage and doubles as the cancel hook). The same callback pumps processEvents(ExcludeUserInputEvents, 10) at ~20 Hz — the window keeps painting, and excluding user input means no click can re-enter a tool mid-run. The image phase (FLUX via generateSourceImageFromPrompt) already spun nested QEventLoops, so it never froze; its SDManager::generationProgressChanged ticks are relayed to the same signal so the bar moves there too. McpToolExecutor connects the signal to AIAgentManager::reportToolProgress → the stepProgressLabel / stepProgress properties (ignored when the agent is not busy; cleared around every callTool), and qml/AIChatPanel.qml's plan card draws a labelled bar (indeterminate when total <= 0). (12) Stop must reach a running heavy tool. The first cut of (11) pumped with ExcludeUserInputEvents, which kept the window painting but made the Stop button unclickable — and the panel had no Stop button at all, so a multi-minute generation could not be aborted. The pump now delivers AllEvents (re-entry is prevented by the agent running one step at a time, not by dropping clicks), MCPServer::requestToolCancel() sets a flag the progress callback returns as false (→ predict() returns cancelled) and which is also checked between the image and 3D phases, AgentToolExecutor::cancelRunningTool() (overridden by McpToolExecutor) is called from AIAgentManager::cancel() while Executing, and qml/AIChatPanel.qml has a Stop button next to the thinking dots. The plan card also stays visible after the run (its planCardPinned guard was never set by anything, so the card vanished with the final result still unread). (13) Window ACTIVATION gates more of QWidget than it looks — this test failed CI twice. ClickFocusFilter_test first asserted a FocusIn count, then (the "fix") hasFocus(); both pass on a developer desktop and fail the Linux lane. Qt gates BOTH on the window being ACTIVE, and a headless CI display (Xvfb, no window manager) never activates one: QWidget::hasFocus() is window()->focusWidget() == this && window()->isActiveWindow(), and QFocusEvents are not delivered at all. window.focusWidget() is the one thing that tracks focus regardless — assert that, and gate anything else behind isActiveWindow(). QT_QPA_PLATFORM=offscreen does NOT reproduce this (it reports isActiveWindow() == true, which is why both bad versions passed locally); a window that is never show()n does, on any platform — ClickFocusFilter.FocusWidgetIsSetEvenWhenTheWindowIsNotActive pins it that way. Rule for new widget tests: assert window.focusWidget(), never hasFocus() or focus-event counts, unless gated on activation. (13-old) A focus-event assertion is not portable. ClickFocusFilter_test asserted a fresh FocusIn after the filter re-asserts focus on an already-focused widget. Qt dispatches QFocusEvent only inside an ACTIVE window, and a headless CI display (Xvfb, no window manager) never activates one — the focus WIDGET still changes but no focus event is delivered, so the test passed on a developer desktop and failed the Linux lane (and the "5954/5955 discovered tests executed" coverage guard failed with it, i.e. one root cause counted as two suite failures). The test now asserts the clearFocus()+setFocus() PAIR via the focus-OUT count (only clearFocus() can produce it) and guards the event-count assertion behind window.isActiveWindow(). Rule for new widget tests: assert focus STATE (hasFocus, window.focusWidget()), not focus EVENT counts, unless the assertion is gated on window activation. (9) Trace log: every task overwrites <AppData>/ai_agent/last_task.log (AIAgentManager::traceLogPath()) with the planner prompts, raw replies and raw tool results — read THAT when a task fails, the chat transcript only shows first lines. Tool routing (src/AIToolRouter.{h,cpp}, the #1002 router v2): the first router was a hand-written keyword table and "create a f22 raptor scene" fell through it (I could not find tools for: generation_3d — the planner asked for the capability by name, the table had no entry for it). Now the registry owns an AIToolRouter: BM25 (k1 1.2, b 0.75) over every tool's name + capability + description + parameter docs, fed by an English intent lexicon (kIntents: user words → tool-vocabulary terms, each with a weight — "green" → material/diffuse at 0.8, but generic verbs like "make"/"create" expand to generation at only 0.25–0.35, else "make it green" routes to TripoSR) plus one rule the lexicon cannot express: a creation verb next to a word NO tool doc mentions (f22, raptor, goblin) is a request to GENERATE that thing, so the unknown noun lifts the generation terms to 0.9. Both sides pass through the same canonical() stem (light suffix strip + trailing-e drop, so dance/dancing and image/images agree — it only has to be CONSISTENT, not linguistically right). route() returns ranked capabilities (scene always kept), a per-tool shortlist (shortlist() prunes a 26-tool capability to the ~10 relevant tools in the prompt — the real context-window win), the expanded terms for the trace, and confident (a raw request word hit a doc and the best score ≥ 1.0). Non-English requests are NOT in the lexicon (users are global, the docs are English): when the route is not confident the agent spends one 40-token LLM round (Awaiting::Intent, requestIntentKeywords) asking for English operation keywords and routes on those — any language, no extra model; a failed round falls back to the lexical route and plans anyway. setIntentKeywordsEnabled(false) in the fixture (the fake tool list has no vocabulary to be confident about). AIToolRouter_test.cpp RoutingBenchmark is the regression bar — every need_capabilities round the trace log records is a routing miss and belongs in its table (must hit every capability, ≥85 % top tool; a miss prints the top-3 scores and the expanded terms so the fix is a table edit, not a guess). An embedding router (e5-small / mmarco cross-encoder) was considered and deferred until the benchmark shows misses the lexicon cannot cover. Test gotcha: fixture tests are TEST_F(AgentFixture, …) — a --gtest_filter='AIAgent*' does NOT run them; use AgentFixture*. Follow-ups (children of #1000): #1004 local VLM viewport inspection (mtmd is already linked for the captioner), #1005 model lifecycle/memory budget, #1006 verifier layer, #1007 e2e benchmark, #1008 batch factory.
  • ModelFetch (src/ModelFetch.{h,cpp}, #1037): the ONE blocking "make sure this model file is on disk" primitive — ModelFetch::ensureBlocking(Request{url,destination,label,timeoutMs,expectedSha256}) -> Outcome{ok,timedOut,path,error}. Twenty consumers used to hand-roll the same nested-QEventLoop wait around ModelDownloader; the copies drifted: 18 DISCARDED the downloader's error text (so "refusing http://" / "SHA-256 mismatch" reached the user as "unavailable (offline?)"), and only 3 (TextureInpaint, FaceRig/ArkitTemplate, FaceRig/FaceLandmarkDetector) guarded the synchronous-rejection race (startDownload emits downloadError synchronously when busy → the handler's loop.quit() fires before exec() and is lost → the caller hangs for its full timeout). ensureBlocking owns only the wait and returns the downloader's own words; consumers keep what genuinely varies. #1025 — an EXISTING file is verified when a digest is known: with Request::expectedSha256 set, a destination that already exists is hashed (ModelDownloader::sha256HexOfFile, cached per process by path+size+mtime so a 1.2 GB decoder is hashed once, not per rig) and a mismatch DELETES it (+ its .part) and re-fetches it, reporting Outcome::replacedCorrupt; without a digest "exists" still means ok. This is what caught the UniRig case below — a same-size corrupted download that loaded fine and produced NaN for months. Consumers with published digests (HF LFS oids) should pass them; UniRig does for its default hosting only (a mirror override may serve a different export) (base-URL env/QSettings/default resolution, the *_NO_DOWNLOAD guard, the timeout). Convention for exposing the reason: ensureModelBlocking(QString* error = nullptr) — done for PhotoDepth and TextureInpaint (their 4 CLI/MCP sites now print e.g. …unavailable: Refusing to download …: scheme 'http' is not https://, proven e2e); the other migrated consumers keep their signatures (no message site to enrich yet). Migrated (behaviour-preserving): every consumer with a blocking wait — PhotoDepth, TextureInpaint (with the error out-param), AIAssistManager, ImageTo3D/ImageCaptioner, ImageTo3D/MeshGenPredictor, ImageTo3D/TripoSGPredictor, ImageTo3D/BackgroundRemover, MeshSegmenter, MotionInbetween, MotionGenerator, SkinTokensPredictor, UniRigPredictor, FaceRig/ArkitTemplate, FaceRig/FaceLandmarkDetector, Mocap/FaceCapPredictor, Mocap/PoseCapPredictor, Mocap/HandCapPredictor. The two FaceRig consumers had their own done race guard — now ModelFetch's settled; their (timeout) breadcrumb annotation comes from Outcome::timedOut. MotionGenerator's old guard timer never cancelled the transfer on timeout; ModelFetch does. Deliberately NOT migrated: MotionLibrary::ensureLibraryBlocking (V1→V2 upgrade logic where a failed download must fall back to the local V1 file — haveLocal ? dest : QString() — not the canonical shape; the only hand-rolled ModelDownloader wait left — other QEventLoops in the tree belong to the cloud client, HDR downloads and the updater, which have their own network paths). Gotcha from this migration: at least one source file is not valid UTF-8 — a Python open(p).read() sweep over src/ raises UnicodeDecodeError; use encoding='utf-8', errors='surrogateescape' for read AND write so bytes round-trip exactly. AIModelCatalog/LLMSettingsWidget are GUI-async (no event loop) and already show the error text. Tests: ModelFetch_test.cpp drives the REAL singleton through QNAM's file:// backend (existing-file short-circuit, real fetch, refusal text verbatim, missing-file network error, synchronous rejection returns in <1 s not after the timeout); the race guard and error capture are mutation-verified.

AI Texture Generation

  • SDManager (src/SDManager.h/cpp): QML_SINGLETON managing stable-diffusion.cpp for AI texture generation. Mirrors LLMManager pattern with worker thread, model management, QSettings persistence.
  • SDWorker (src/SDWorker.h/cpp): Worker thread wrapping stable-diffusion.cpp C API. Handles model loading (new_sd_ctx), image generation (generate_image), and progress callbacks.
  • Integration: MaterialEditorQML connects to SDManager signals. Generated textures are saved as PNG, registered as Ogre resource locations, and applied to the current material's texture unit.
  • Models stored in <AppData>/sd_models/. Supports .safetensors, .ckpt, .gguf formats.
  • #ifdef ENABLE_STABLE_DIFFUSION guards all sd.cpp includes/calls. Feature is OFF by default.
  • When both features are enabled, sd.cpp and llama.cpp share the same ggml dependency managed by CMake.

AI-Assisted Authoring (epic #397)

  • MeshOptimizerLod (src/MeshOptimizerLod.h/cpp, issue #398): Thin facade over zeux/meshoptimizer for LOD generation. Free functions, no singleton. generateLods(mesh, reductions) returns one LodLevel per requested reduction, each with one Ogre::IndexData* per submesh. Uses meshopt_simplifyWithAttributes when UV0 is present (preserves UV seams), falls back to meshopt_simplify otherwise. Every result is meshopt_optimizeVertexCache-reordered (Forsyth) so the LOD is cache-friendly out of the box. Caller takes ownership of the IndexData* (commit to SubMesh::mLodFaceList or call destroyLevel).
  • MeshLodController (src/MeshLodController.h/cpp): Now has Algorithm enum (Ogre | Meshopt) on the C++ overload generateLods(int, const QVariantList&, Algorithm). QML-facing generateLodsWithAlgo(int, QVariantList, QString) accepts "ogre" / "meshopt" for the Inspector backend dropdown. Default is Ogre — meshoptimizer's attribute-weighted simplify preserves UV seams + skin weights but in practice produces a softer silhouette than Ogre's stock MeshLodGenerator on character meshes, so Ogre stays primary. CLI: --algo ogre|meshopt (default ogre). MCP generate_lods tool: algo param (default ogre). Sentry breadcrumb category ai.assist.lod records the chosen backend when meshopt is used.
  • MeshDecimator (src/MeshDecimator.h/cpp): Same Algorithm enum exposed on decimateEntity(entity, reduction, algo). MeshDecimatorController::applyReductionWithAlgo(double, QString) is the QML-facing variant the Inspector's Decimate section dropdown calls. CLI qtmesh decimate ... --algo ogre|meshopt; MCP decimate_mesh algo param. Same default and breadcrumb category as the LOD path (ai.assist.decimate for meshopt). The post-decimation promoteFirstLodToBase also erases the qtme.faces.<i> n-gon bindings, otherwise FBXExporter (and EditableMesh) rehydrate the original triangle list off the cached binding and emit the un-decimated mesh.
  • Mesh-aware texture generation (src/MeshDepthRenderer.h/cpp + MaterialEditorQML::generateMeshTextureFromPrompt, issue #403): depth-conditioned (ControlNet) texture generation. Renders a grayscale depth map of the selected entity (MeshDepthRenderer: offscreen RTT, auto-framed camera from the -Z front, flat white-emissive material + scene LINEAR fog for the near=white/far=black gradient — no custom shaders; the grid node + bounding box + other entities are hidden during capture so only the target silhouette is captured), feeds it to sd.cpp's sd_img_gen_params_t.control_image with a ControlNet depth model (control_v11f1p_sd15_depth, auto-discovered in the sd_models dir; falls back to plain txt2img if absent), and applies the result to the active material's diffuse. All #ifdef ENABLE_STABLE_DIFFUSION-guarded (flag is OFF by default). Surfaced via the "Use selected mesh (depth-conditioned)" checkbox in the Material Editor's existing AI texture-generation panel (qml/TexturePropertiesPanel.qml, not a separate dialog), the MCP generate_mesh_texture tool, and the CLI qtmesh material <file> --generate-texture "<prompt>" [--model <name>] [--controlnet <path>] [--controlnet-strength <0..1>] [--width N] [--height N] [-o out] (CLIPipeline::cmdMaterialGenerateTexture). The GUI/MCP paths are fire-and-forget (the long-running process applies the result on SDManager::generationCompleted); the CLI is a one-shot _exit() process so it loads the base model and drives the async SDManager worker synchronously via two local QEventLoops (one for modelLoadCompleted/modelLoadError, one for generationCompleted/generationError/generationStopped), then copies the PNG next to the output mesh, binds it as the diffuse TUS on every submesh, and re-exports. SDWorker::generateTextureControlled carries the control image; recreateContext loads control_net_path + disables vae_decode_only when a ControlNet is set. Known limitation: in-app application of the generated diffuse to RTSS-rendered PBR materials whose diffuse TUS is unnamed/numeric is unreliable (the Cook-Torrance SRS only recognizes named albedo/diffuse_map slots) — as a workaround the Material Editor's Preview section has a "Save Texture As…" button (exportCurrentTexture) so the user can export the generated image and apply it via other tools. Mapping is planar/view-projection (not a full UV reprojection bake). Sentry breadcrumb category ai.assist.mesh_texture.
  • PBR map synthesis from albedo (src/PbrMapSynth.h/cpp + src/AIAssistManager.h/cpp, issue #404): predicts normal + height maps from a single albedo/diffuse texture via an ONNX UNet (DeepBump-style) and derives roughness from albedo luminance. First ONNX consumer. cmake/OnnxRuntime.cmake downloads the prebuilt ONNX Runtime 1.20.1 per-platform behind ENABLE_ONNX (OFF by default; ON for release + the Linux coverage build) and exposes the imported qtmesh_onnx target — macOS uses the universal2 archive (no per-arch trap; CoreML EP inside, CPU EP fallback). PbrMapSynth is the Ogre-free, unit-tested core (NCHW packing, overlapping-tile inference with feathered seam blend, normal/height decode with strength + OpenGL/DirectX invertG, roughness heuristic); it discovers the model's input channel count + output names/shapes at runtime rather than hardcoding, and falls back to the Sobel NormalMapGenerator to derive a normal when the model emits only height. Model: PBRify_Remix (CC0-1.0). Three separate per-map SPAN models (1x-PBRify_NormalV3 / RoughnessV2 / Height, all 3-channel-in/3-channel-out, ~1.6 MB each as ONNX) from Kim2091/PBRify_Remix, trained only on CC0 AmbientCG/Poly Haven textures — redistributable with zero obligations (DeepBump is GPL-3.0 and was rejected). License due-diligence (#404): the repo's own LICENSE is CC0-1.0 and its README states the models were "trained exclusively on high quality CC0 content from ambientCG". OpenModelDB's NormalV3 page lists the training set as "ambientCG + UltraSharpV2", and UltraSharpV2 is itself cc-by-nc-sa-4.0 — a discrepancy. We treat the author's explicit repo CC0 LICENSE + "exclusively CC0" statement as authoritative (the OpenModelDB note is third-party/likely stale) and ship on that basis; revisit if the author clarifies otherwise. They ship as PyTorch .pth; scripts/export-pbrify-onnx.py is the one-time, offline, NOT-shipped dev tool that converts them to ONNX (spandrel load → torch.onnx.export opset 18, dynamo=False, dynamic H/W). AIAssistManager (QML_SINGLETON, SDManager pattern) resolves each map's model under AppData/ai_models/pbr/<file>.onnx, downloads any missing ones on first use via ModelDownloader (base URL from QSettings ai/pbrModelBaseUrl or QTMESH_PBR_MODEL_BASE_URL env, defaulting to the hosted fernandotonon/QtMeshEditor-models HF repo via kDefaultModelBaseUrl), caches outputs next to the source albedo, and binds normal/roughness into the slice-E canonical slots (RTShaderHelper::wirePbrSlotsForFFP). Normal decodes the model's RGB as tangent-space; roughness/height take Rec.601 luminance of the RGB output. Roughness falls back to the offline luminance heuristic when its model is absent, so a roughness-only request always works. Synchronous (ONNX is fast — no worker thread), with pbrSynthStarted/Completed/Error signals for the GUI. Surfaced via the "Generate PBR maps from diffuse" button in the Material Editor's Texture Properties panel (qml/TexturePropertiesPanel.qml, shown only when aiPbrAvailable()), the MCP generate_pbr_maps tool, and the CLI qtmesh material --texture <albedo> --generate-pbr [<mesh>] [-o out] [--tile-size N] [--no-normal] [--no-roughness] [--no-height] (CLIPipeline::cmdMaterialGeneratePbr). Roughness needs no model so a --no-normal --no-height request succeeds offline; a normal/height request fails gracefully (exit 1, no output) when the model is missing or the binary was built without ENABLE_ONNX. Sentry breadcrumb category ai.assist.pbr_synth. Windows MinGW: ENABLE_ONNX stays OFF (the official ONNX Runtime archive is MSVC-built and won't link under MinGW) — the feature degrades to the "rebuild with -DENABLE_ONNX" message; a follow-up can wire it. The exported .onnx files are hosted at fernandotonon/QtMeshEditor-models (kDefaultModelBaseUrl) and download on first use; override with QTMESH_PBR_MODEL_BASE_URL / ai/pbrModelBaseUrl, or set QTMESH_PBR_NO_DOWNLOAD to force the offline path (tests do this).
  • Texture inpainting (src/TextureInpaint.{h,cpp}, issue #1017, epic #818 Track C3): fills masked regions of a texture with plausible, seamlessly continued content via LaMa (Suvorov et al., WACV 2022 — Apache-2.0 code AND weights). Ogre-free + Qt-only (QImage in/out), the PbrMapSynth/BackgroundRemover shape, so the pure pieces unit-test without a GL context. Two silent traps the tests pin down: (1) the graph takes the image in [0,1] but returns 0..255 — feeding an un-scaled image saturates the output (measured mean 246.6 vs a correct 128.6) with no error raised; (2) the mask is binarised, since the model was trained on a hard 0/1 mask and passing soft grey through measurably changes the fill. Spatial dims are pinned at 512², so larger textures run as overlapping tiles with a feathered seam blend. Unmasked texels are composited back bit-exact rather than trusting the model to preserve them (it does — measured leakage MAE 0.0000 — but compositing makes that structural). maskDilatePx (default 2) grows the mask first: a mask ending exactly at the bad pixels leaves the model conditioned on the half-bad texels just outside it, dragging the artifact back in. Non-finite output falls back to the SOURCE pixel, so a numerically broken graph degrades to "unchanged" rather than to noise (the #1025 lesson). An RGBA source keeps its alpha (the model is RGB-only, so a naive path returns a fully-opaque image and silently flattens a cutout texture — inpainted texels are forced opaque, since invented colour under alpha 0 would be invisible). ensureModelBlocking guards a real event-loop race: ModelDownloader::startDownload emits downloadError synchronously when another download is already active, so a handler's loop.quit() would fire before exec() and be lost — hanging for the full timeout and then cancelling the other caller's download; the settled flag skips exec() in that case, and only OUR timeout cancels. Three consumers: (a) UV-island seam fill — MeshGenBaker::Result::coverage publishes the pre-dilation per-texel chart coverage (the dilation pass only smears border colour outward to stop filtering bleed; it does not continue the texture), and MeshGenPredictor consumes it via TextureInpaint::maskFromCoverage behind Options::inpaintSeams (CLI --inpaint-seams, MCP inpaint_seams) — opt-in, since the model is a ~200 MB first-use download, and it falls back silently to the dilated bake rather than failing a whole generation. NB the coverage is sized to the BAKED TEXTURE, not to Options::textureSize: xatlas picks its own atlas dimensions (59² for a 64² request), so a mask must be built from texture.width()/height(); (b) the texture-paint Inpaint button, which fills the current smart selection — it composites the whole layer stack first, because an inpainter is context-driven and the bare active layer would condition it on transparent surroundings; (c) groundwork for #805's MV-Adapter. Surfaces: CLI qtmesh material --texture <img> --inpaint --mask <m.png> [-o out] [--mask-dilate N], MCP inpaint_texture, and the Inpaint buttons in the paint panel + detached texture editor. Model ai_models/pbr/lama.onnx (~200 MB) downloads on first use (QTMESH_INPAINT_MODEL_BASE_URL / ai/inpaintModelBaseUrl; guard QTMESH_INPAINT_NO_DOWNLOAD). Hosting gotcha: the Carve/LaMa-ONNX repo's plainly-named lama.onnx is BROKEN (fails ORT shape inference on a DFT node); lama_fp32.onnx is the working graph, re-hosted under the plain name. scripts/export-lama-onnx.py refuses to host a graph that does not load, emits non-finite values, alters unmasked texels, or leaves the masked region unchanged. Sentry breadcrumb ai.assist.texture_inpaint.
  • Real-ESRGAN texture upscaling (src/TextureUpscaler.h/cpp + AIAssistManager, issue #405): ONNX-backed 2×/4× super-resolution, reusing the #404 ONNX infra. TextureUpscaler is the Ogre-free core (reuses PbrMapSynth::toNCHW/nchwToRgb): a scale-aware overlapping-tile upscale that composites results in OUTPUT space with a feathered seam blend, detecting the scale factor from the model's output/input ratio at runtime (and validating the output tensor element count before copying — guards a mismatched-shape model). AIAssistManager::upscaleTexture(srcPath, scale, overwrite) extends the per-model Map enum with UpscaleX2/UpscaleX4, downloads the model on first use (same HF repo), runs, caches <stem>_upscaled_x{2,4}.png next to the source, and emits upscaleStarted/Completed/Error. The Material Editor path is worker-threaded and reports state via upscaleDownloading (first-run model fetch) / upscaleProgress(done,total) (per tile) / upscaleCompleted/upscaleError; cancelUpscale() flips a shared atomic that the tiling loop's ProgressFn checks (returns ok=false, error="cancelled"). The QML shows "Downloading upscale model…" / "Upscaling… tile X/Y" and a Cancel button. Model: Real-ESRGAN x4plus / x2plus (BSD-3-Clause, xinntao) — the repo LICENSE has no code/weights carve-out and OpenModelDB classifies the released weights as BSD-3; exported to ONNX via scripts/export-realesrgan-onnx.py (one-time, offline, NOT shipped). Surfaced via CLI qtmesh material --texture <low> --upscale {2|4} [-o <high>] (CLIPipeline::cmdMaterialUpscale), the MCP upscale_texture tool, and "Upscale 2× / 4×" buttons in the Material Editor's Texture Properties panel. Sentry breadcrumb category ai.assist.upscale. ONNX intra-op threads are set to hardware_concurrency-1 (leaving one core free for the UI/host) — a 256² → 1024² 4× dropped from ~2 min (single-threaded) to ~7.5 s (~7 cores) on an M-series laptop; CoreML EP on macOS helps further. (The thread bump is scoped to the upscale session only — PbrMapSynth stays single-threaded since its maps are small/fast.) Verified end-to-end: 256→1024 (4×) and 128→256 (2×) with the model auto-downloaded.
  • LLM-assisted material from a description (issue #406): natural-language → material via the existing local LLM. The GUI already shipped this (Material Editor "Generate" field → MaterialEditorQML::generateMaterialFromPrompt → LLMManager::generateMaterial); #406 adds the missing CLI + MCP parity by reusing that exact path headlessly. The shared core CLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error) resolves a GGUF model (the --model/model override, else last-used / first available via LLMManager::scanForModels+availableModels), drives LLMManager::generateMaterial synchronously through two QEventLoops (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts the material <name> header, parses the script via MaterialManager::parseScript, compile()s, honors a pbr_workflow tag through RTShaderHelper::applyPbrIfTagged, and binds the material to every submesh of the entity. The CLI qtmesh material <file> --describe "<prompt>" [--model <name>] [-o out] (CLIPipeline::cmdMaterialDescribe) imports → applies → re-exports; the MCP describe_material tool (MCPServer::toolDescribeMaterial, args {prompt, mesh?, model?, output_path?}) applies to the named/selected entity in-session and optionally re-exports when output_path is given. Both fail gracefully (exit 1 / error result, no output) with a clear "no LLM model found …" message when no model is loaded or the build has no llama.cpp — LLMManager.cpp always compiles, so no #ifdef ENABLE_LOCAL_LLM guard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb category ai.assist.describe_material. No new constrained-JSON contract or PBR-param mapping was added — the existing free-form Ogre-material-script generation already produces good materials, and duplicating it would only add surface; this slice is purely the headless parity layer.
  • Skinning v2 (issue #819, Slices A+B): SkinWeights::Algorithm { InverseDistance, GeodesicVoxel, SkinTokens } — SkinTokens (the ML skinner) is the default on every surface (GUI dialog dropdown, CLI qtmesh skin --algo skintokens|geodesic-voxel|inverse-distance, MCP compute_skin_weights algo param; "unirig" survives as a deprecated alias of skintokens), falling back to GeodesicVoxel when models/ONNX are unavailable — so headless/CI environments transparently get the geodesic bind. qtmesh rig --skin, MCP auto_rig {skin:true} and the GUI rig+skin checkbox all chain through the same default. GeodesicVoxelBind (src/GeodesicVoxelBind.h/cpp, Ogre-free + unit-tested) implements Maya's "Geodesic Voxel" bind (Dionne & de Lasa, SCA 2013): voxelize the surface at --voxel-res (default 64, max 256; Akenine-Möller tri/box SAT), flood-fill the exterior to classify interior (closes holes at voxel resolution → works on non-watertight/self-intersecting/multi-component meshes), 3D-DDA bone rasterization with snap-to-solid (max 4.5 voxels — far-outside bones get NO seeds and are reported in bonesWithoutSeeds), one multi-source Dijkstra over solid voxels (26-conn) keeping the best K=8 (bone, distance) pairs per voxel, then per-vertex (1/d)^falloff weighting. Distances travel through the volume so cross-limb bleed (hand-near-thigh, inner thighs) is impossible by construction. Degenerate input (planes/cloth — zero interior voxels) falls back to InverseDistance automatically; vertices in bone-less floating islands get inverse-distance fill so they still move with the rig. SkinTokens falls back to GeodesicVoxel when its models/ONNX are unavailable (Slice C). SkinWeightsPost (src/SkinWeightsPost.h/cpp, Ogre-free + unit-tested) runs after EVERY algorithm inside computeAndApply: Laplacian relaxation over the vertex adjacency (--smooth-iterations, default 3, 0=off; merge-mode manual weights act as Dirichlet constraints — they influence neighbours but are never modified) then prune <0.01 + top-K + renormalize. Report gains algorithmUsed, fallbackReason, bleedFraction (fraction of committed weights not geodesically local — 0 for GVB by construction), bonesWithoutSeeds. Sentry breadcrumbs ai.assist.skin.<algo>. qtmesh rig --skin and MCP auto_rig {skin:true} chain through the same default. Slice D — dual-quaternion display toggle: SkinningDisplay (src/SkinningDisplay.h/cpp) toggles RTSS hardware skinning per entity (Linear = default path; Dual Quaternion = HardwareSkinningFactory::prepareEntityForSkinning(ST_DUAL_QUATERNION) + technique invalidation — kills candy-wrapper collapse on twists). The HS factory + a dormant template SRS are registered in RTShaderHelper::initialize (bone cap 96; above-cap entities stay on the default path); Linear mode erases the material's HS_SRS_DATA imprint (mirrors Ogre's file-local constant). Display only — exported weights unchanged. Surfaced as the "Display: Linear | Dual Quaternion" row in the Animation-mode Skinning section and MCP set_skinning_display {mode}; mode tracked on the entity's UserObjectBindings; Sentry render.skinning. Slice E — evaluation suite: SkinMetrics (src/SkinMetrics.h/cpp, pure-data: influence histogram, Laplacian weight-smoothness energy, LBS deform + signed mesh volume) and SkinEvaluate (src/SkinEvaluate.h/cpp: extract EXISTING weights from an entity, metric report incl. geodesic bleed, position-matched/name-matched comparison vs a reference-skinned copy — equidistant duplicate verts tie-break on minimum weight diff so seams/contact points don't report spurious diffs). CLI qtmesh skin --evaluate / --compare <ref>; acceptance fixtures in SkinMetrics_test.cpp (90° elbow capsule volume ≥ 0.9 — measured 0.911; proximity bleed 0 vs inverse-distance >0.1; smoothing strictly reduces energy); Mixamo protocol + thresholds in docs/SKINNING_QUALITY.md; env-gated reference test via QTMESH_SKIN_OURS_FBX/QTMESH_SKIN_REF_FBX. Slice C — SkinTokens ML skinning (implemented, the DEFAULT per user preference — visually the best skinner in practice): Algorithm::SkinTokens runs SkinTokens/TokenRig (VAST-AI, MIT code + MIT weights; Qwen3-0.6B backbone) — UniRig's own skin head stays blocked on spconv/PTv3 (no ONNX lowering; decision record in THIRD_PARTY_AI_MODELS.md). SkinTokensPredictor (src/SkinTokensPredictor.h/cpp, the EIGHTH ONNX consumer; pure-data parts unit-tested): surface-sample num_points (8192) points+normals, normalise mesh+joints per the upstream AugmentAffine (uniform scale, joints included in the AABB, exact [-1,1] fit), tokenize the skeleton TEACHER-FORCED (DFS stream, multi-root topologies re-parented to the first root, "articulation" cls token), then five graphs: mesh_cond/vae_cond/embed/decoder (Qwen3 KV-cache step; ships as proto + decoder.onnx.data external weights — ORT 1.20.1 SIGSEGVs parsing the 1.66GB single-file proto)/skin_decode (FSQ folded in), greedy skin-token decode constrained to the FSQ vocab, per-joint weight decode, 8-NN IDW transfer to full-res verts. Geodesic localisation pass in the dispatch (SkinWeights.cpp): raw SkinTokens weights are diffuse (the upstream demo voxel-masks them by default) — we filter per-vertex to GVB's geodesically-local bone sets + renormalise (bandit: bleed 0.74→0.05, mean L1 vs artist weights 1.72→1.22; GVB baseline 1.09 on that metric, but visual quality favours the ML result, hence the default). Export: scripts/export-skintokens-onnx.py (offline; flash-attn shim + eager attention + CPU stubs + traced FPS + RMSNorm decomposition + transformers-5.x cache API; parity vs torch ~1e-5 on every graph); hosting: scripts/upload-skintokens-models.sh → HF models repo skintokens/ (~2.3GB, downloads on first use; QTMESH_SKINTOKENS_MODEL_BASE_URL/ai/skintokensModelBaseUrl, guard QTMESH_SKINTOKENS_NO_DOWNLOAD). ~12 min for 119 bones/90k verts on an M-series CPU — report says algorithmUsed: "skintokens"; every failure falls back to GeodesicVoxel with a reason. Ort C++ footgun for future consumers: GetTensorTypeAndShapeInfo() is a NON-OWNING view — never chain it off a temporary TypeInfo. Debug tracing: QTMESH_SKINTOKENS_DEBUG=1.
  • Skel Slice D — interactive skin-weight painting (issue #558): paint bone weights directly on the mesh in the viewport. src/WeightPaintOps.{h,cpp} (pure-data, unit-tested) is the brush core: BrushMode{Add,Subtract,Blur} × BrushShape{Round,Square}, falloffWeight (exponent 1+falloff*5, matching EditModeController::applyVertexColorBrush so both brushes feel identical), applyDab, fillConnected (GEODESIC BFS — follows the surface, so a fill cannot leak across a gap that is merely spatially close), mirrorByPosition, plus thin wrappers over SkinWeightsPost for normalize/smooth/limit-influences. The load-bearing primitive is writeWeightHoldingTarget: it writes the painted value and rescales the OTHER unlocked bones into the remaining headroom, so the row sums to 1 with the painted value INTACT. Do NOT use setWeight + normalizeRow for a user-driven weight change — normalizeRow rescales every entry including the one just written, so lowering a SOLE influence renormalises it straight back to 1.0 (shipped as the "cannot subtract once a vertex reaches 1.0" bug, which appeared independently in BOTH the brush and the numeric per-vertex setter). At exactly 1.0 the zero-weight siblings have already been pruned, so the painted bone is the row's only influence and there is nowhere for freed weight to go; DabOptions::fallbackBoneHandle names a recipient (the controller supplies the painted bone's PARENT — weight leaving a forearm belongs on the upper arm) and an existing zero-weight sibling is preferred over adding one. On a single-bone skeleton a sole influence legitimately stays pinned (the row has no other way to sum to 1) — pinned by a test so it reads as intentional, not a regression. src/SkinWeightController.{h,cpp} (QML_SINGLETON) owns the session: extract once via SkinEvaluate::extract, edit in memory as the user strokes, write back on a debounced QTimer::singleShot(0) tick. The write path is deliberately the IN-PLACE one — clearBoneAssignments + addBoneAssignment* + _compileBoneAssignments() per owner, with NO Entity::_initialise — because that re-packs BLEND_INDICES/WEIGHTS into the SAME vertex buffer the live SkeletonInstance already references; swapping VertexData out from under a live skeleton is what shatters the on-screen mesh (see the UV-unwrap notes). _compileBoneAssignments is O(assignments) for the whole owner, so it runs once per debounce tick, never per dab. Owner order (shared vertex block first as submeshIndex -1, then each non-shared submesh) must match SkinEvaluate::extract exactly; vba.vertexIndex is OWNER-LOCAL while the weights array is globally indexed. Undo via WeightEditCommand (m_skipFirstRedo — the live edit already happened; resolves its entity by NAME, never a cached pointer). Bone LOCKS are stored by name so they survive a skeleton rebind. Brush settings (radius/strength/falloff/shape) are read from EditModeController, the canonical owner, so the weight brush and the vertex-colour brush share one set of controls. Overlay feedback (BoneWeightOverlay): refreshColours() re-reads the weights and restamps the cached per-vertex colours while REUSING the existing geometry — rebuildVisuals() destroys and recreates the whole ManualObject and is far too heavy to run mid-stroke; it is called from the controller's flush so the heat map tracks the stroke live. Optional per-vertex DOTS (setShowVertices) give the depth cue the heat map cannot: the heat map runs depth-check OFF so it bleeds through the surface, leaving it ambiguous which side of the mesh a colour is on, whereas the dots are depth-TESTED and get occluded by the geometry. Dots are emitted as up to THREE point-list sections — unconnected verts (flat grey, no halo) → each connected vert's dark HALO → its heat-map-coloured fill; a GL point cannot have an outline, so the "border" is a larger dark point drawn underneath, and the halo is limited to vertices actually weighted to the selected bone (halving their cost and keeping unrelated parts of the mesh from competing visually). Connectivity is read from the ASSIGNMENT LIST, not the cached colour — a vertex weighted 0.0 and one with no assignment share the same ramp colour. Render-queue order is load-bearing and pinned by a test: mesh (RENDER_QUEUE_MAIN) → heat map (MAIN+1) → dots (MAIN+2) → skeleton (MAIN+3, set on SkeletonDebug's bone-attached link/joint/axes entities). Sharing a queue with the translucent heat map left the order undefined and it painted over the dots (they were emitted, attached and inside a valid bounding box, yet invisible); an EARLIER queue does not work either, since the depth-write-off overlay is then covered by the opaque mesh. Paint mode and the heat map are bound both ways: entering paint shows the overlay (setWeightPaintEnabled), and hiding the overlay exits paint (AnimationWidget::toggleBoneWeights — the single point the Inspector checkbox, MCP, and the PropertiesPanelController selection/mode-change cleanups all funnel through). Showing the heat map alone does NOT enter paint mode. Because the two now call into each other, createVertexMaterial() must re-resolve all three MaterialPtrs on every call — an early return once the fill material resolved left the halo/plain pointers dangling after a MaterialManager teardown and crashed inside MaterialManager::getByName on re-enabling paint. Viewport routing lives in TransformOperator (press/move/release when mTransformState == TS_SELECT, hoisted OUT of the edit-mode branch since weight paint is an ANIMATION-mode feature); the controller does its OWN screen→local hit test against the session geometry rather than TexturePaintController::hitTestLocalPoint, which early-returns unless a TEXTURE paint session exists and therefore silently skipped every dab. Cursor: OgreWidget's brushCursorActive() predicate covers both paint modes at all four cursor sites. Sentry scene.skel.weight.*. Tests: WeightPaintOps_test.cpp (pure-data brush/ops incl. the subtract-at-1.0 cases), SkinWeightController_test.cpp (setter clamping, safe degradation with no session, snapshot round-trip), BoneWeightOverlay_test.cpp (colour ramp + material config), BoneWeightOverlayRuntime_test.cpp (the LIVE-session cases the others cannot reach: dots are emitted by the update TIMER not the enable call, draw order, real AnimationWidget parenting, the toggle binding + its non-recursion, and the re-enable-after-disable crash).
  • SkinWeights (src/SkinWeights.h/cpp, issue #402): inverse-distance ("closest-point-on-bone") automatic skin weights (now the fallback algorithm — see Skinning v2 above). The issue proposed wrapping libigl's bounded biharmonic weights (BBW), but BBW requires tetrahedralization via TetGen — which is GPL/copyleft. Adopting it would force the entire binary to GPL and close off Homebrew / Snap / WinGet redistribution under the project's permissive-license stance. This first slice ships a native heuristic with zero new dependencies: for each vertex, compute its distance to every bone's segment (line from bone-head to the average of its children, falling back to point distance for leaf bones in the skeleton's bind pose), apply 1/dist^falloff weighting, keep the top-K bones (default K=4 matches hardware skinning), and normalize. This is the same algorithm Maya / 3dsMax use as their default "smooth bind." Distance cap (maxInfluenceDistance × mesh-diagonal) prevents a finger bone from picking up weight on a foot. Optional skipUnweightedBones filters Mixamo helper bones. replaceExisting=false enables a merge mode for "fill in missing weights" workflows. Surfaced via qtmesh skin --max-influences N --falloff F -o out, MCP compute_skin_weights, and the Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button (qml/SkinWeightsDialog.qml, driven by SkinWeightsController singleton). Lives in Animation Mode (not Edit Mode) because skinning governs how the mesh deforms under animation — a rigging step, not a mesh-topology edit. The button binds to hasSkinnedSelection so it disables on static (skeleton-less) meshes. The GUI path runs through ComputeSkinWeightsCommand (src/commands/) so the auto-skin is undoable (Ctrl+Z): the command snapshots every submesh's VertexBoneAssignmentList (+ the mesh-level shared list) before the first redo, runs computeAndApply, and on undo restores the snapshot and calls _compileBoneAssignments to re-pack the blend buffer. (Unlike the UV-unwrap restore, recompiling is safe here because the vertex buffer object is unchanged — only the blend bytes are rewritten.) Sentry breadcrumb category ai.assist.skin_weights. A future slice can plug libigl BBW in behind -DENABLE_LIBIGL_BBW for users who accept the GPL implications. Verified on Rumba Dancing.fbx: 69 bones, 5828 verts → 20,129 vertex-bone assignments (avg 3.45 influences/vert), valid glTF round-trip.
  • AutoRig (src/AutoRig.h/cpp, issue #407): native automatic rigging — predicts a skeleton for an unrigged mesh. The issue proposed wrapping Pinocchio (Baran & Popović, SIGGRAPH 2007), but Pinocchio's core library is LGPL-2.1-or-later (only its demo CLI is MIT). Statically vendoring LGPL imposes relink / object-file obligations that conflict with this project's statically-linked, permissively-redistributed binaries (Homebrew / Snap / WinGet / Docker) — the same reason #401 (Instant Meshes / QuadriFlow) and #402 (libigl BBW needs GPL TetGen) shipped native heuristics. Pinocchio's algorithm (embed a skeleton template into the mesh interior) is published and unencumbered; only its code is LGPL, so this is a from-scratch native implementation with zero new dependencies. Pipeline: (1) read mesh vertices → AABB; (2) each built-in template (humanoid 19-bone / biped / quadruped / generic) is a proportional joint graph in a normalised unit box; map every joint into the AABB; (3) recentre flagged joints (spine, limb roots) toward the centroid of the vertices in a thin slab at the joint's up-height — pulls the spine onto the medial line and lands limb roots inside the silhouette. rigEntity() builds an Ogre::Skeleton (parent-relative bone positions, setBindingPose), binds via mesh->_notifySkeleton + entity->_initialise(true) — the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate on entity->hasSkeleton()) silently drop the new rig. Pure-data core (templateJoints / fitTemplate) is unit-tested without GL. Surfaced via qtmesh rig <file> [--skeleton T] [--skin] [--up-axis x|y|z] -o out (CLIPipeline::cmdRig, optionally chains SkinWeights::computeAndApply for one-click rig+skin), MCP auto_rig {template, skin?, up_axis?, output_path?} (MCPServer::toolAutoRig), and the Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button (qml/AutoRigDialog.qml, driven by AutoRigController singleton, gated on hasRiggableSelection — a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb category ai.assist.auto_rig. Quality limits (documented per the issue, like Pinocchio): heuristic embedding — works best on roughly upright, single-component, manifold, T/A-pose meshes with +Y up; it does not detect limbs from topology, so exotic proportions or non-upright poses can misplace joints. Verified end-to-end: a static OBJ → 19-bone humanoid + skin → glTF export with 1 skin / 17 joints. Mixamo-style marker placement (refinement over the proportional fit): the user clicks the 10 humanoid markers on the mesh surface in the viewport (chin, L/R shoulder, L/R wrist, L/R hip, L/R knee, hips/pelvis — AutoRig::humanoidMarkerOrder()), and each placed marker anchors its joint while the limb/spine chains interpolate between the anchors so the rig follows actual body proportions instead of the fixed template. The pure-data core is AutoRig::fitTemplateWithMarkers and it does coherent inference, not per-marker patching: it runs fitTemplate for a proportional baseline (and to read the template's segment vectors / lateral offsets), then resolves an anchor for every key joint (Head, L/R Shoulder, L/R Hand, Hips, L/R UpLeg, L/R Knee) as marked → inferred-from-marked-neighbours → template and lays the dependent chains (spine, arms, legs) from those anchors — so a partial marker set yields an anatomically-sane skeleton instead of mixing marked anchors with stranded template joints (no shoulder-above-head). Inference: Hips ← midpoint of marked up-legs + template socket→pelvis rise; Head ← template offset above resolved Hips; UpLeg ← mirror the other up-leg across the pelvis, else pelvis + template socket offset; Shoulder ← along the live Hips→Head line at the template shoulder-height fraction + template lateral offset, else mirror the other; Hand ← shoulder + template arm vector (marked shoulder + skipped wrist still lays a full arm); Knee/foot ← clamped to the mesh AABB so an inferred leg never punches through the model: when the knee is skipped, the foot is dropped straight to the mesh FLOOR (mn[up]) below the up-leg and the knee placed halfway between (template thigh-vector extrapolation, which used to shoot feet past the lower limit, is only used for the small forward knee nudge); a marked knee keeps its position with the foot extrapolated below but still floor-clamped. Mirroring reflects across the sagittal plane (side axis auto-detected). An empty marker set early-returns fitTemplate unchanged; report.markersApplied counts only user-placed markers. (Legacy per-marker description retained below for the chain mechanics.) The old behaviour was: Hips→anchor pelvis AND carry the thigh roots (LeftUpLeg/RightUpLeg, children of Hips) by the same delta so the whole pelvis+thigh cluster moves as a unit — unless an explicit hip marker overrides; Chin→anchor Head AND lay the spine straight up from the pelvis — Spine/Chest/Neck distributed evenly between Hips and Head by index (cartoon torso lengths vary too much for a proportional guess); L/R shoulder→anchor the arm-chain attach point (applied before the wrist so the chain lays from the marked shoulder); L/R wrist→layChain lays the WHOLE arm straight from the shoulder anchor — Shoulder[anchor]→Arm[⅓]→ForeArm[⅔]→Hand[marker] — distributing every intermediate joint so the entire arm reaches the wrist, not just the hand; L/R hip→anchor the thigh root/hip socket (applied before the knee, overrides the hips-carry — needed for cartoon legs that splay at odd angles); L/R knee→layLeg anchors the knee at the marker and continues the foot below it along the thigh→knee direction (so the whole leg — hip socket → knee → foot — follows the marked hip + knee)). layChain is generic (anchor-first, marker-last, evens the middle by index) so adding more chain joints is a one-line change. Every marker is OPTIONAL — unset markers keep the template fit (report.markersApplied counts the placed ones; an empty marker set is bit-identical to fitTemplate). The viewport flow lives in AutoRigController (marker-session state machine: beginMarkerPlacement/skipCurrentMarker/undoLastMarker/cancelMarkerPlacement/commitMarkerRig); clicks are routed in by TransformOperator::mousePressEvent (checked before the knife/select paths when markerMode() is true), ray-cast to the mesh surface (getCameraToViewportRay → Möller-Trumbore against world-space triangles), stored in mesh-local space, and shown as unlit-yellow PT_SPHERE overlays. The whole UI is inline in the Inspector's "Rigging" section (riggingToolsComponent in qml/PropertiesPanel.qml) — there is no separate dialog (the old AutoRigDialog.qml was removed). It show/hides smartly: idle shows the two entry points ("Place markers…" / "Auto-Rig (template)"), a skin checkbox, and an "Advanced options" checkbox that reveals the template + up-axis pickers; while markerMode is active it swaps to the per-marker guidance label + Skip/Undo/Cancel/"Rig from markers" controls. Rig state + the runAutoRig/runMarkerRig helpers live on the PropertiesPanel root; the section's onSectionVisibleChanged cancels any active marker session if the section disappears (mode change / deselect / re-rig), replacing the dialog's old onClosing cancel. No CLI/MCP marker surface — guided placement is inherently interactive. UniRig ML backend (issue #408): AutoRig::Algorithm {Pinocchio, UniRig} selects the skeleton-prediction backend (default Pinocchio — offline, deterministic). UniRig (Zhang et al., "One Model to Rig Them All", SIGGRAPH 2025, VAST-AI-Research/UniRig — MIT code + MIT weights, trained on Articulation-XL2.0 CC-BY-4.0) is an autoregressive transformer that predicts a skeleton from the mesh geometry, handling arbitrary/non-humanoid topology better than the fixed template. It's the second ONNX consumer after #404 PbrMapSynth. RigNet was rejected for #408 (GPL code + unlicensed weights + non-public ModelsResource dataset — fails the project's permissive-redistribution bar); UniRig is the clean permissively-licensed alternative (see THIRD_PARTY_AI_MODELS.md). UniRigPredictor (src/UniRigPredictor.h/cpp, Ogre-free + unit-tested) is the C++/ONNX runtime that ports UniRig's skeleton stage: (1) surface-sample up to 65536 points + normals, normalise into a centred unit box (+Y up); (2) run the Michelangelo encoder (encoder.onnx, pc[1,N,3]+feats[1,N,3] → latent prefix); (3) greedy/constrained autoregressive decode over the ~350M causal-LM (decoder.onnx) with a manual KV-cache + the tokenizer's next-possible-token validity mask (a documented simplification of UniRig's beam+sampling — deterministic + exportable, still yields a valid tree); (4) the exact tokenizer FSM from src/tokenizer/tokenizer_part.py (256 coord bins, continuous_range [-1,1], undiscretize(t)=(t+0.5)/256*2-1, branch/parent rules, vocab 267) → joints (de-normalised) + parent indices, parent-before-child ordered for Ogre. The detokenizer + undiscretize are public statics (UniRigPredictor::detokenize/undiscretize) so they're unit-tested without ONNX. Everything is ENABLE_ONNX-guarded; two model files (AppData/ai_models/unirig/{encoder,decoder}.onnx) download on first use via ModelDownloader (ensureModelBlocking, 180s timeout, returns the encoder path only when BOTH exist; base URL override QTMESH_UNIRIG_MODEL_BASE_URL / QSettings ai/unirigModelBaseUrl, offline guard QTMESH_UNIRIG_NO_DOWNLOAD). UniRig falls back to Pinocchio (logged in report.fallbackReason) when ONNX is off / the models are missing/offline/not-yet-hosted / prediction is unusable — reliable offline. Design contract / hosting status: UniRig is an autoregressive HF AutoModelForCausalLM + a Michelangelo perceiver — no single-graph ONNX export exists upstream; scripts/export-unirig-onnx.py (one-time, offline, NOT shipped) exports the encoder + a KV-cache decoder to the I/O UniRigPredictor targets. The three graphs ARE hosted (unirig/{encoder,decoder,embed}.onnx, ~1.44 GB). #1025 post-mortem: --algo unirig fell back to the template on every call for months because the encoder produced 100% NaN latents. The hypothesis on the issue ("the export is numerically broken") was WRONG: the HF-hosted encoder is finite and produces sane, input-sensitive latents; the reporting machine's downloaded copy was byte-identical in SIZE but had a ~8.6 MB corrupted region (309,884 bytes differing from HF) that landed in two resblocks.2 weight matrices as NaN/±3e38. UniRigPredictor::expectedSha256() now carries the three HF LFS oids and ensureModelBlocking routes every file through ModelFetch with them (default hosting only), so a corrupt copy is detected, deleted and re-fetched before the graph is loaded; the NaN-latent guard in predict stays as the last line of defence. Lesson: when a hosted graph "produces NaN on every input", hash the local file against the publisher's oid BEFORE blaming the export. Known export limitation (follow-up): the traced farthest-point sampling was frozen as constants — the encoder graph runs only for N ≥ 2048 points (N < 2048 fails a Gather bounds check) and for larger N picks a fixed index subset rather than true FPS; the C++ always feeds ≥ 4096 sampled points, so it works, but a re-export with dynamic FPS (or FPS done in C++ feeding exactly 2048 points) would restore the paper's sampling. UniRig is marker-incompatible (markers are a template concept), so a marker-driven call always uses the template. Surfaced via CLI qtmesh rig <file> --algo pinocchio|unirig (CLIPipeline::cmdRig; rignet accepted as a deprecated alias), MCP auto_rig algo param (MCPServer::toolAutoRig), and the Inspector Rigging-section Algorithm segmented picker; the report carries algorithmUsed + fallbackReason, and the Sentry ai.assist.auto_rig breadcrumb records the algo. Frozen query sampling (#1046) and how the C++ compensates: the export traced the Michelangelo encoder's farthest-point sampling into constants — Constant_174 holds 4096 query indices (1794 unique, all < 2048) into the INPUT cloud, so the graph runs only for N ≥ 2048 points and the perceiver's QUERIES are whatever sits in the first 2048 input slots, while the whole cloud still feeds the Fourier/kv path. Point ORDER is therefore part of the input. UniRigPredictor::frontLoadFarthestPoints (pure, tested) puts a greedy FPS of the whole sampled cloud first (upstream: fps(ratio=1/4, random_start=False) over all 65536 points). Measured on real meshes (qtmesh rig --algo unirig, skeleton renders inspected): FPS-first rescued a biped rat (6 → 24 joints: spine, branching arms, legs) and gave a car its four wheel clusters (29 → 35), left the humanoid unchanged (52), but collapsed a stylized oak from a good 64-joint trunk-and-branches rig to a 7-joint stick; restricting the FPS pool to the first 4096/8192 points was worse on every mesh. So Options::querySampling defaults to Both: decode with the FPS-first and the as-sampled ordering and keep the richer skeleton (pickRicher: a failed run never wins, more joints wins, tie → fps) — on every mesh measured the visibly better rig was the one with more joints, since the constrained greedy decode under-produces rather than over-produces. Costs one extra decode per rig; QTMESH_UNIRIG_QUERIES=fps|random|both overrides; Result::querySampling/alternativeJoints record the outcome. A re-export with dynamic FPS would make the single faithful ordering sufficient again. Category-aware joint naming (#1013, slice 1): UniRig's cls tokens are DATASET classes (vroid / mixamo [untrained] / articulationxl), not semantic categories — articulationxl (token 266, what the C++ seeds) already spans humans, animals and articulated objects, so there is no quadruped/vehicle conditioning to plumb; the humanoid limitation was entirely our post-processing: labelJointsAnatomically stamped Hips/Neck/Head/LeftFoot on a car (its LONG axis reads as the spine) and RightArm_3 on a tree. It now RETURNS a plausibility verdict — the axis it detected as up must equal the caller's up axis, and exactly one Left and one Right chain must classify as arm and as leg — and UniRigPredictor::Options::labeling {Auto, Humanoid, Generic} (default Auto) applies humanoid names only when that holds, else labelJointsGeneric (root, bone_01, … — unique by construction). applyLabeling returns which was used; Result::labeling → AutoRig::Report::jointLabeling → JSON jointLabeling (CLI --json, MCP auto_rig). --skeleton (CLI/MCP/GUI template picker) doubles as the category hint for UniRig (AutoRig::uniRigLabelingForTemplate): humanoid → Auto, biped → Humanoid FORCED (the user asserts a biped — the cartoon rat's arm chains fail the plausibility check, so Auto names it generically; --skeleton biped gets the humanoid names), quadruped/generic → Generic outright. Text-to-motion then refuses a generically-named rig with its "humanoid rigs only" message instead of animating a wheel as a foot. detokenize() keeps its legacy default (Humanoid) so the pure tokenizer tests are unchanged. Vehicle template + rigid binding (#1013, slice 2): UniRig has a strong humanoid prior on fused low-poly bodies — ugly_car.obj came back as a 52-joint HUMAN LYING ALONG THE CAR (spine down the length, finger fans at the front, legs at the back) under BOTH point orderings, so no chooser or naming rule can fix it; only the Buick, whose wheels are separate geometry, got a spine-plus-wheel-clusters rig. Hence AutoRig::Template::Vehicle (--skeleton vehicle, alias car; in the Inspector list; MCP template:"vehicle"): kVehicle = Chassis root → FrontAxle/RearAxle → four wheels, NO slab recentring (it would pull wheels onto the centre line); AutoRig::fitVehicle box-fits the table and then SNAPS each wheel to the centroid of the lowest 30 % of the body in its front/back × left/right quadrant (the LONGER in-plane axis is the length, front = its high end; left = −side, the labeller's convention), axles to their wheel-pair midpoints, chassis to the body centre at 35 % height; a quadrant with no low geometry keeps the box default. Two robustness rules, both learned on the Buick and pinned by tests: (1) the fit works on OCCUPIED SPACE — the cloud is deduplicated into 96³ voxel cells and every cell counts once — because a vertex-weighted centroid is at the mercy of tessellation: the Buick carries ~9,800 vertices collapsed onto ONE point at the origin (a degenerate primitive) that outnumber a whole wheel and dragged the rear-right wheel onto the centre line; (2) "ground" is the bottom of the LARGEST height-contiguous run of occupied cells, not the AABB bottom — a detached blob floating below the body (the Buick's) is separated from it by an empty vertical gap and would otherwise own the low band and pull every wheel down to it. A "lowest DENSE bin" rule was tried first and rejected: thin tyres never form a dense bin, so it picked the body as the ground and silently dropped the wheels out of the band. With --algo unirig, the vehicle hint SHORT-CIRCUITS the model (fallbackReason says so) — the geometric rig is the better answer for vehicles. Rigid binding: SkinWeights::rigidOptions() (1 influence, NO distance cap so no vertex is left unbound, 0 smoothing) with Algorithm::InverseDistance — every vertex follows exactly one bone. Surfaces: qtmesh rig … --rigid (implies --skin), qtmesh skin … --rigid, MCP auto_rig {rigid:true}; and it is IMPLIED by the vehicle template on every surface (AutoRig::templateIsRigid; GUI AutoRigController::chainSkinForTemplate, which the controller chains for a rigid template even with the skin box unticked). GUI gotcha fixed here: the Inspector's "Skeleton type" picker was HIDDEN whenever UniRig was selected (it was a template-only input before slice 1), so the category hint silently stayed on humanoid and a user picking UniRig + car got the lying-human rig twice over (Both mode) with no way to reach vehicle. The picker now stays visible in UniRig mode as "Skeleton type (category hint)" with a one-line per-template explanation. Tests: AutoRig_test.cpp AutoRigVehicle.* (synthetic box body + four wheel discs, length along Z and along X — wheels within 0.25 of the disc centres, axle ordering, chassis centred, string/hint/rigidity round trips). Remaining #1013 work: category-aware root selection for creatures, the CC0 validation set, and a "lying humanoid" detector that could auto-suggest the vehicle template. Remaining #1013 work: category-aware root selection, rigid binding defaults for vehicles/props, the CC0 validation set. (The early qml/AutoRigDialog.qml reference above is stale — the UI is inline in qml/PropertiesPanel.qml's Rigging section.)
  • 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 <file> -o <out> [--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 <AppData>/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 per-target names natively (#921): Assimp's glTF2 exporter writes mesh.extras.targetNames (the Blender/Godot/three.js convention) from aiAnimMesh::mName — but ONLY under the export property GLTF2_TARGETNAMES_EXP, which MeshImporterExporter::assimpExportProperties() now sets at all three Assimp::Exporter sites (exporter, exportCurrentPose, sceneExporter). Its importer reads them into mName, which MeshProcessor prefers, so names round-trip with no sidecar; the <file>.arkit.json sidecar is written only for formats that cannot carry names and is still READ for files from older builds. Docs: docs/FACE_RIG.md; spike/contract: docs/FACE_RIG_SPIKE.md. Orientation (the "rig lands on the back of the head" bug, fixed): the fit assumed the user head faces the template's +Z "by contract" — NRICP's prealign is centroid+scale only, and constellationResidual (the auto-anchor quality gate AND the marker left/right-swap heuristic) scored a rotation-FREE similarity, so any head facing −Z / +X / any yaw had its correct auto anchors REJECTED as garbage (residual ≫ 0.15), its marker pairs wrongly mirror-swapped, and the template draped over the back of the skull; markers could not help. src/FaceRig/FaceRigAlign.{h,cpp} (pure, reuses FaceCapPose::solve) solves a Horn similarity (proper rotation, never a reflection) from the anchors; buildFaceRig rotates the user mesh + anchors into the TEMPLATE frame about the anchors' centroid, runs the unchanged fit, and rotates the deltas back (legacy path bit-identical below 3°). constellationResidual now delegates to rotationAwareResidual, which keeps the mirrored-placement detection working (a reflection still scores high under any proper rotation). With < 3 anchors, FaceRigOptions::faceDirHint (the landmark view ranking's faceDirLocal, returned by buildLandmarkAnchors(..., &faceDir) from the SAME detection) turns the head to +Z by the minimal rotation (rotationToPlusZ: yaw + pitch, roll unrecoverable); with neither, the old front-facing contract remains. FaceRigResult/AttachReport carry orientationSource (anchors|face_dir|none) + angle, echoed in CLI/MCP JSON and a ai.assist.face_rig breadcrumb. Three knock-on fixes the rotation-aware gate required: marker seeding's consensus outlier prediction AND the detector-bias subtraction were rotation-free (a trusted turned head got its good detections replaced by MIRRORED predictions) — both now use the solved rotation; the marker left/right swap only fires on a clear win (rs < 0.5*rn), since under a rotation-aware residual a reflection of a near-symmetric constellation differs from a 180° turn only by depth relief; and ensureLandmarkModelOnce() downloads face_landmarks.onnx at most once per process and never on a non-ONNX build (the auto path previously never fetched it, so fresh installs silently had no detector). FaceRigController reserves busy (m_preparing) across the nested-event-loop model waits so the UI cannot start a second worker. Tests: FaceRigAlign_test.cpp, FaceRigger_test.cpp (user turned 180° must fit as tightly as the reference and its smile delta must point −Z).
  • Audio2Face lipsync (src/AudioToFace/, issue #1019, epic #818 C1): speech → ARKit blendshape weight keyframes, via NVIDIA's Audio2Face-3D v2.3 "Mark" running locally through ONNX. The key architectural fact: no mesh correspondence is needed. The network emits a deformation of NVIDIA's own character, but NVIDIA also ships that character's ARKit-52 deltas (bs_skin.npz), so the weights are recovered by projecting the predicted motion onto those 52 shapes — and weights carry no topology, so they drive ANY mesh with ARKit targets (51 of our 52 names match theirs verbatim). Pipeline: WAV → mono → 16 kHz → centred 8320-sample window per frame → network.onnx (+ 26-dim emotion) → 301 coefficients → PCA reconstruct (A2FCoefficients) → box-constrained projected-gradient solve against the ARKit basis (BlendshapeSolve, L2/L1/temporal/symmetry, NVIDIA's shipped strengths) → morph keyframes. Zero new dependencies: both npz archives are STORED (compress_type 0) so NpzReader walks the zip directory and .npy headers directly (no zlib), and WavReader walks RIFF chunks rather than assuming a 44-byte header. LipsyncApply.{h,cpp} is the shared surface-agnostic core (name binding + key-time selection + the write) used by all three surfaces, because two rules are silently wrong if reimplemented: (1) a keyframe carries EVERY matched pose, not just the changed ones — ARKit targets on a submesh share one VAT_POSE track and Ogre interpolates a pose missing from the next keyframe TOWARD ZERO (applyToVertexData: "if not there, will be 0"), which made steady channels dip to 0 and back (measured: 39 dips across 14 channels on one 59-frame take); the epsilon decides WHEN to key, never WHICH poses a key carries; (2) an existing clip of the same name is REPLACED, not merged into (writeWeightKeyOn reuses tracks and only overwrites coincident times, and clip length only grows). mouthClose convention trap: ARKit defines it as a counter-shape to jawOpen, but NVIDIA's character authors it with the OPPOSITE sign (cosine with jawOpen +0.41 on their basis vs −0.46 on a true ARKit rig), so the solved weight over-drives the lower lip on replay — corrected by a 0.5 multiplier (kMouthCloseScale) through NVIDIA's own bsWeightMultipliers table, keyed by pose NAME since the npz is a zip and its entry order is arbitrary (poseNames inside it is authoritative). Surfaces: CLI qtmesh lipsync (AudioToFace::LipsyncCLI, deliberately NOT behind ENABLE_MOCAP — animating from a file must not require the webcam/Qt-Multimedia stack), MCP generate_lipsync (heavy, advertised only on an ENABLE_ONNX build), and the Inspector "🎙 Lipsync from Audio (AI)…" button (LipsyncController: model fetch on the MAIN thread before the worker starts — ModelFetch drives the singleton ModelDownloader and its QNAM/timers, which live there — then solve on a worker, commit via LipsyncClipCommand as ONE undo step; a bare beginMacro around direct writes would group nothing). Emotion is USER-SUPPLIED, never predicted: NVIDIA's Audio2Emotion is separately licensed for use only with Audio2Face and is deliberately not shipped. Models download from NVIDIA's own HF repo (the one model we do not mirror) with their published LFS oids pinned and verified on disk as well as on download. Sentry ai.assist.audio2face.
  • QuadRetopo (src/QuadRetopo.h/cpp, issue #401): triangle-pairing quad-dominant retopology. The issue proposed wrapping Instant Meshes (Wenzel Jakob), but Instant Meshes ships as a research GUI app with no clean C++ library API and has been dormant since 2016. QuadriFlow (the production-grade alternative used by Blender 3.0+) requires Boost + Eigen + LEMON — heavy deps the project doesn't currently use. This first slice ships a native triangle-pairing backend with zero new dependencies: walks every interior edge whose two adjacent faces are triangles and scores the merge by (1) coplanarity (dot product of triangle normals; default maxAngleDeg=25°), (2) quad shape (deviation of interior angles from 90°; default shapeToleranceDeg=65°), (3) aspect ratio (longest/shortest edge; default maxAspectRatio=6.0). Pairs are taken greedily best-first; each triangle claimed at most once. Quads are emitted with opposing-corner winding (opposing0, sharedA, opposing1, sharedB). Output goes through EditableSubMesh::faces → triangulateFaces (fan retri for GPU) → writeNgonFacesToMesh (n-gon binding for exporters / Edit Mode). No new vertices are introduced, so UVs and skin weights survive unchanged. Backends are pluggable via the Algorithm enum (only TrianglePair implemented; future QuadriFlow / InstantMeshes slot in here). Surfaced via qtmesh retopo --target-faces N --max-angle DEG -o out, MCP retopologize, and the Material Mode → Mode Tools → "Quad Retopology…" button (qml/QuadRetopoDialog.qml, driven by QuadRetopoController singleton). Sentry breadcrumb category ai.assist.retopo. Verified on Rumba Dancing.fbx: 10,220 tris → 6,032 faces (4188 quads + 1844 tris), 82% quad dominance. Hard lower bound on face count is ~50% of input (every triangle paired); strict gates typically land 60-70%.
  • MeshSegmenter (src/MeshSegmenter.h/cpp, issue #410 + categories #818 B2): AI mesh part segmentation — predicts a semantic part label per vertex + per face, with category-specialised models sharing ONE global Part vocabulary: body (head/torso/L+R arm/L+R leg — meshseg.onnx, the original 7-channel wire contract), vegetation (trunk/branch/foliage/root/flower — meshseg_vegetation.onnx), vehicle (vehicle_body/wheel/window/wing/rotor — meshseg_vehicle.onnx), building (wall/roof/window/door/chimney/foundation — meshseg_building.onnx; window is one global label shared with vehicle). Options::category {Auto, Body, Vegetation, Vehicle, Building}; Auto runs a tiny point-cloud category classifier (meshseg_category.onnx, PointNet max-pool 4-way, ~0.1 MB) via resolveCategoryBlocking() — classifier unavailable/offline → Body (the pre-B2 behaviour). Local model channels map to global parts via categoryChannelMap(); the geometric fallback is category-aware (vegetation foliage/trunk, vehicle wheel/body, building roof/wall up-bands); bone-proximity hints apply only to Body (they're body-part indices). One model per category (NOT one big softmax — label imbalance, coupled failures, full re-download per addition; decision + SmolVLM-as-dispatcher rejection recorded in docs/MESH_SEGMENTATION_STRATEGY.md; SmolVLM stays a follow-up GUI "identify/name" assist). CLI --category, MCP category arg; all models download from HF segment/ under the same env/QSettings overrides. The fourth ONNX consumer; powers Edit-Mode "Select by part", per-part material assignment, and auto-rig priors. Geometric fallback is first-class (always compiled, Ogre-free): segmentGeometric does connected-component islands (connectedComponents, union-find) + an up-axis/lateral spatial heuristic (top→head, lower→legs, mid-sides→arms, centre→torso), overridable per-vertex by rig bone-proximity hints — used automatically when the build lacks ONNX, the model is missing/un-downloadable, or inference fails (Result::usedModel/fallbackReason report which ran). The ONNX path (#ifdef ENABLE_ONNX, PointNet++-style) normalises → deterministic point sample → [1,N,3] → per-point argmax over the part channels → scatters labels back to all vertices by nearest sampled point (runtime I/O-name discovery, channels-first/last handling, CoreML EP). ensureModelBlocking() downloads meshseg.onnx to AppData/ai_models/segment/ (override QTMESH_SEGMENT_MODEL_BASE_URL / QSettings ai/segmentModelBaseUrl; offline guard QTMESH_SEGMENT_NO_DOWNLOAD; non-ONNX #ifndef guard) — the #408/#409 pattern. Pure-data helpers (connectedComponents, facesFromVertexLabels) are unit-tested without Ogre/GL. Split-cleanup passes (#863, default ON via Options::cleanupIslands, applied after labelling on BOTH the model and geometric paths, then vertex labels reconciled via vertexLabelsFromFaces): smoothLabelBoundaries straightens ragged part seams (the zigzag "fringe" teeth where the torso meets the legs) by flipping boundary faces that a strict majority of their edge-neighbours put in the other part (iterated, order-independent snapshot per pass); cleanupLabelIslands reabsorbs small DISCONNECTED face-islands near junctions (the floating fragments a split otherwise leaves) into the majority boundary-neighbour label — an island is a stray only if < islandMinFaces (32) AND < islandMaxFraction (2%) of its label AND not the label's largest island. Both operate on the FACE graph (shared-edge adjacency via buildFaceAdjacency), so they clean the exact thing PartOps split routes by. levelLimbCut (default ON via Options::levelLimbCuts, BODY category only) mirror-symmetrises the TWO LEG cuts across the sagittal plane so an explode is symmetric while preserving the model's natural DIAGONAL boundary (like the arms). It reflects the labelling across the leg region's lateral centre and makes each near-seam face agree with its mirror (union rule: a face is a limb if it OR its mirror is; torso only if both are), scoped to faces within a few edge-hops of the leg↔torso seam via a bounded flood. This fixes leg asymmetry WITHOUT a flat horizontal recut — a first horizontal-h version dragged the torso skirt into the legs and SWAPPED feet (reflection maps a foot to the opposite foot with limb labels swapped, so it keeps each foot with its own leg). Arms EXCLUDED (already symmetric; passed leg labels only). Verified on Hip Hop Dancing.obj: leg size ratio 0.84→1.00, feet stay on their own side, torso skirt not dragged (leg up_max = natural diagonal), arms untouched (2624/2624); rigged Rumba stayed balanced. planarBoundaryRecut (axis-snapped separating-plane recut + mirror-limb coupling for a fully knife-clean cut) exists but is EXPERIMENTAL / OFF by default (Options::planarRecut=false) — the band-reassign was too coarse and scrambled real characters; superseded in practice by the narrower levelLimbCut, kept for future refinement. Surfaced via CLI qtmesh segment <file> [--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 <mesh> --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 <dir> 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.
  • Lattice deformer (src/LatticeDeformer.{h,cpp}, src/LatticeController.{h,cpp}, src/commands/LatticeCommands.{h,cpp}): the Blender "Lattice modifier" — box a mesh with an nx×ny×nz grid of control points, drag points in the viewport, the enclosed vertices bend live (Sederberg-Parry free-form deformation). Lattice::Grid is the pure-data core (Ogre-buffer-free, LatticeDeformer_test.cpp): fromBounds(aabb, nx, ny, nz, padding) builds the rest grid (resolutions clamped to [2,16]; a FLAT axis — a plane — gets 2 % thickness so normalisation is finite), deform(p) is the tensor product of three 1-D bases — Linear (trilinear, C0, creases at cell walls), Smooth (Catmull-Rom/cardinal, C1, LOCAL 4-point support, interpolates the points; the default — Blender's B-spline look without the Greville-abscissa headache), Bezier (Bernstein, degree = res−1, GLOBAL smooth influence). Every basis has partition-of-unity + linear precision, so identity at rest is exact; Catmull-Rom end handling uses linearly-extrapolated GHOST points (a duplicated end point halves the end tangent and bends a rest lattice near its shell — pinned by a test). Points OUTSIDE the box are NOT extrapolated (a degree-15 Bernstein explodes past [0,1]); they carry the displacement of the clamped boundary point, which is continuous. The deformation is a function of the REST positions (deformAll(rest)), never of the current ones — that is what makes it non-accumulating, makes reset bit-exact, and lets the same lattice replay on another asset. LatticeController (QML_SINGLETON, Object mode, PartOpsController pattern) owns the session: beginSession loads an EditableMesh from the selected entity, snapshots per-submesh rest positions, builds the grid from EditableMesh::calculateBounds(); every edit runs applyDeform → setVertexPosition for every vertex (ALL submesh copies — EditableMesh duplicates shared vertex data into each submesh and commitToEntity uploads only submesh 0's copy) → a QTimer::singleShot(0)-coalesced recalculateNormals + commitToEntity (the vertex-paint flush idiom: moves arrive faster than a 90k-vertex commit). Two undo levels: LatticeGridCommand per drag/reset/load/resolution/interpolation change INSIDE the session — it snapshots the whole qtmesh-lattice-v1 document before/after (so a resolution change, which reshapes the point array, is as undoable as a drag), lives on a SESSION-LOCAL QUndoStack (LatticeController::m_sessionUndo, attached via UndoManager::setSessionStack — undo()/redo() act on the session stack while it has entries and FALL THROUGH to the global stack otherwise, so undoing the previous bake from inside a new session still works; push() always targets the global stack) and is cleared when the session ends, so the global history never accumulates dead no-op entries (review finding; the clear is deferred with m_inUndoReplay when the session ends from inside a replaying command — QUndoStack::clear() deletes the executing command). The commands are also STAMPED WITH THE SESSION ID (m_sessionId) as a second guard. LatticeApplyCommand is the BAKE: dense [submesh][vertex] rest positions + REST NORMALS + deformed positions, BOUND to the Ogre::Mesh* it was recorded on (a same-name replacement with a compatible layout is refused, not overwritten), alreadyApplied skips the first redo, resolves the entity by NAME, calls abandonSessionFor(name) first (a live session's rest snapshot would no longer describe the mesh), writes through EditableMesh in place — no _initialise, so skinned entities keep their live SkeletonInstance. Authored normals survive cancel/undo: EditableMesh::commitToEntity(entity, recomputeNormals) gained the flag — a bend commits with recompute (normals follow the surface), while cancel / bake-undo write the rest positions AND the snapshotted normals verbatim with recompute OFF (a recompute would smooth hard edges — the previous edit-mode-style write path always recomputed). Adding a lattice never uploads (identity over a mesh at rest is skipped via m_meshDirty). Applying a rest lattice just closes (no empty undo step). Changing the resolution rebuilds a REST lattice and drops the bend (the old points have no meaning on the new grid — Blender does the same) but the whole previous lattice is one Ctrl+Z away. Entity replacement is detected: resolveEntity() looks the NAME up in the SceneManager and requires the same pointer AND the same Ogre::Mesh (m_meshIdentity); anything else (destroyed, or swapped under its node the way SplitMeshCommand does) ends the session before any dereference — never trust m_entity raw (review P1). The overlay node/objects are explicitly NAMED (LatticeOverlay_<sid>…) because Ogre registers only named nodes, and destroyOverlay checks liveness by name. Entering Edit Mode cancels the session (Edit Mode owns the same vertex buffers). Viewport: TransformOperator routes press/move/release to beginDrag/updateDrag/endDrag while sessionActive() and the SELECT tool is active (the SkinWeightController "brush owns the drag" idiom; a miss clears the point selection and is SWALLOWED — falling through would deselect the entity under the cage), hover via updateHover; mTrackingEnable/cursor gain a lattice term. Picking is screen-space nearest-within-12px (worldToScreen via proj*view, behind-camera rejected, nearer point wins an overlap); the drag moves the selected points in the camera-facing plane through the grabbed point, anchored at PRESS time (the bone-drag mBoneDragGizmoOrigin lesson — anchoring to the live point drifts). a press that misses every point starts a RUBBER-BAND box select (reuses TransformOperator's m_pSelectionBox; release inside 3 px = plain click → deselect) via selectPointsInRect; Shift-click toggles / Shift-box adds, Ctrl+A selects all, Enter applies, Esc cancels (MainWindow::keyPressEvent, non-modal — other keys pass through). Cage overlay = OT_LINE_LIST wires + three OT_POINT_LIST sections (plain/selected/hovered, setPointSize, the BoneWeightOverlay dot recipe) with depth check OFF so points behind the surface stay grabbable, on a DEDICATED child node of the entity's node (mesh-local geometry follows the transform; never attach a ManualObject to the entity node — ObjectItemModel static_casts attachments to Entity*), RENDER_QUEUE_OVERLAY, query flags 0. Persistence: qtmesh-lattice-v1 JSON (Grid::toJson/fromJson, strict — bad resolution/short points/unknown interpolation rejected) via GUI Save/Load Lattice… (saveLatticeRequested/loadLatticeRequested → MainWindow runs the QFileDialog, the PoseLibrary split). The box is in MESH-LOCAL space, so a lattice replays onto any asset with a comparable local frame. Surfaces: Inspector Object-mode "Lattice Deform" section (latticeDeformComponent in qml/PropertiesPanel.qml, stays visible while a session is open even if the selection changes; resolution SpinBoxes, Linear/Smooth/Bézier segments, Add/Apply/Cancel/Reset/Select All/Save/Load); MCP lattice_begin {entity_name?, resolution?, interpolation?} (cancels any live session FIRST, then applies settings) → lattice_set_points {points | moves:[{index, position|delta}], reset?} (validates EVERY entry — finite numbers, index range — before applying anything, then lands as ONE undo step via setPoints) → lattice_apply / lattice_cancel, lattice_get, and the one-shot lattice_deform {lattice | lattice_path, entity_name?, output_path?} (heavy; undoable via LatticeApplyCommand with alreadyApplied=false); JSON coordinates are validated as finite numbers everywhere (Grid::fromJson — QJsonValue::toDouble would read a string as 0); CLI qtmesh lattice <mesh> --apply <lattice.json> -o <out> / --info [--resolution nx,ny,nz] / --info <lattice.json> (CLIPipeline::cmdLattice, lattice is in AppLaunchHandler's subcommand list). LatticeController::deformEntityWithGrid is the shared one-shot core (deforms CURRENT positions — for a rest asset that equals the session result). AI capability mesh_deform in AICapabilityRegistry. Sentry mesh.lattice.begin|apply|cancel|reset|drag|resolution|interpolation|save|load. No gamification cluster — the cloud DISCOVERY_FEATURES list has no mesh-edit key and the keys must match. Tests: LatticeDeformer_test.cpp (bases, identity, corner/centre weights, locality of Smooth vs Bezier, outside-shell continuity, JSON) + LatticeController_test.cpp (no-scene command branches + GL-gated session: live GPU deform, in-session undo, bake as ONE undo step, rest-lattice apply adds no step, JSON round trip, resolution rebuild, one-shot deform).
  • 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/solidify math is headless-testable): groupFacesByLabel (face labels → stable FaceGroups), splitByFaceGroups (#861 — one submesh per (part label, source material); duplicates boundary vertices so parts are independent; preserves normals/uv/colour/tangent/bone-assignments/n-gon faces; excluded groups dropped; optional connected-component sub-split; per-label name suffixes head, head.1…), joinParts (#862 — merge parts baking world transforms into positions/normals; same-material submeshes coalesce), explodeOffsets (#862), and solidify (#863 follow-up — gives a thin-shell part real wall volume; also the thing that makes a cut read as solid). (The #863 3D-print alignment-peg sub-feature AND the capOpenBoundaries cut-face capper were both REMOVED. Pegs: real dowel/socket connectors on organic AI-segmented character joints proved unreliable — no safe flat cut plane through a hip seam (it also slices the belly), which is why Meshy/Tripo cut organically but ship no discrete pegs. Cap: closing the cut RING is geometrically watertight but a thin single-sided game-shell still LOOKS hollow at the cut (its own back-wall sits right behind the flat cap); a recessed-rim variant was tried and created artifacts, so cap was dropped. preparePrintPegs/buildAlignmentPegs/estimateBoundaryPlane, AddPrintPegsCommand, Manifold CSG, capOpenBoundaries, SplitOptions::capParts, the explode "Cap open boundaries" toggle, --print-pegs, MCP prepare_print_split, and the "Prepare for 3D Print" button are all gone. Split + explode/join + solidify (opt-in, which seals thin shells) is the shipped scope.) PartOpsMesh is the Ogre adapter: reads an entity into attribute-complete EditableSubMeshes (same submesh-then-local triangle order as AutoRig::gatherGeometry, so faceLabels map 1:1), runs the split, and builds a fresh Ogre::Mesh via EditableMesh::createNewMesh(recomputeNormals=false) — rebinding the source skeleton + recompiling bone assignments so SKINNED characters stay riggable, and naming each submesh (Mesh::nameSubMesh) with its part. Part names round-trip through FBX (FBXExporter emits getSubMeshNameMap names → Assimp aiMesh::mName → MeshProcessor nameSubMesh) and show in the Scene tree (SceneTreeModel prefers the registered submesh name over the positional index). Surfaces: CLI qtmesh segment --split-parts -o out / --write-labels; GUI Object-mode Inspector "Split into Parts (AI)" section (inspector-native controls (InspectorCheckBox + InspectorButton idiom + ThemedComboBox)) → PartOpsController::splitSelectedIntoParts → undoable SplitMeshCommand; MCP split_mesh_by_segments (same command). SplitMeshCommand swaps the whole mesh on the scene node — a submesh-count change can't go through the in-place EditMeshTopologyCommand/resizeEntityBuffers path; Ctrl+Z restores the fused mesh. It clears the SelectionSet's entity + sub-entity references BEFORE destroying the old entity (they only auto-clean on sceneNodeDestroyed, but the node survives a mesh swap — skipping this dangles the transform-gizmo / Scene-tree pointers and crashes), then reselects the node. Slice A (#860) segmentation-preview caching lives in EditModeController (partGroups(), select/hide/exclude/rename, cleared on edit-mode exit + topology change). Slice C (#862) — explode/join scene nodes: PartOpsScene (src/PartOpsScene.{h,cpp}) is the SCENE-level Ogre adapter above PartOpsMesh (which builds one mesh) — pure builders that compute the target scene state but never mutate the graph (the undo commands own node create/destroy so undo can replay). explodeEntity(entity, distance, base) splits every submesh of a fused mesh into its own single-submesh Ogre::Mesh (preserving attributes/material/skeleton+bone-assignments, part name via getSubMeshNameMap) and computes an outward SubMeshOps::explodeOffsets per part (from part-vs-assembly centroids × distance × assembly diagonal); joinEntities(entities, base) reads each entity's submeshes + its node's _getFullTransform() into SubMeshOps::JoinParts (world transform baked into positions, inverse-transpose into normals/tangents) and merges via joinParts (same-material coalesce; skeletons NOT reconciled — join yields static geometry). ExplodePartsCommand (src/commands/): redo destroys the fused node and creates N sibling part nodes at srcTransform + orient·(scale∘offset) (offset applied in the source node's local frame), reselecting them; undo destroys the parts and recreates the fused node bound to the resident original mesh. JoinPartsCommand: redo captures each part's mesh + node TRS (for undo), destroys the part nodes, creates ONE fused node at the ORIGIN (positions already world-baked) reselecting it; undo destroys the fused node and recreates every part with its captured transform. Both use create-then-destroy ordering in BOTH redo and undo (new part/fused names never collide with the node being replaced, so they coexist momentarily) — the replacement is fully built + validated before the old node is destroyed, and a creation failure rolls back leaving the original intact, so the scene is never orphaned. Both clear the SelectionSet before destroying entities (SplitMeshCommand's dangling-sub-entity-ref rationale), preserve the source node's parent (parts/fused node are reparented back under the same group via Manager::reparentNode + explicit local-TRS restore) and reject nodes with child nodes (a subtree they don't serialise) with a clear error. PartOpsMesh::readSubMeshes prefers the entity's effective per-SubEntity material (SubEntity::getMaterialName) over the base SubMesh name so a Material-Mode override isn't lost on split/explode/join; SubMeshOps::joinParts reverses triangle winding + flips tangent handedness under a mirror (negative-determinant) transform so a negative-scaled part doesn't join back-facing. GUI: Object-mode Inspector "Explode / Join Parts" section (partOpsExplodeJoinComponent in qml/PropertiesPanel.qml) → PartOpsController::explodeSelected(distance) / joinSelected(), gated on new canExplode (one multi-submesh selection) / canJoin (2+ selected) props. Breadcrumbs mesh.parts.explode / mesh.parts.join. Slice E (#864) — CLI/MCP parity: CLI qtmesh segment <file> --explode-parts [--explode-distance <d>] [--solidify] -o scene.glb (CLIPipeline::cmdSegment: splits → PartOpsScene::explodeEntity → one scene node per part composing the ORIGINAL imported node's world TRS with the outward offset (srcPos + srcOrient·(srcScale∘offset), so a non-identity source node's placement/rotation/scale survives) → MeshImporterExporter::sceneExporter multi-node glTF; the original imported node is destroyed first so the exported scene isn't doubled by an intact un-exploded copy); MCP explode_mesh_parts ({entity_name?, distance?}, undoable via ExplodePartsCommand) and join_mesh_parts ({entity_names?} — omit to join all mesh entities; undoable via JoinPartsCommand), both registered heavy + mapped to the ai_assist gamification cluster. segment_mesh already returns face_labels always (the epic's return_face_labels). Print-split/pegs were descoped (removed this epic). Breadcrumbs mesh.parts.segment_preview / mesh.parts.split_segments. Body-centric labels; glTF coalesces same-material parts (FBX preserves them). Tests: SubMeshOps_test.cpp (incl. join rotation-bakes-normals), SplitMeshCommand_test.cpp, ExplodePartsCommand_test.cpp / JoinPartsCommand_test.cpp (no-Ogre error-branch), PartOpsMesh_material_coverage_test.cpp (GL-gated: readSubMeshes prefers the effective per-SubEntity material override), CLIPipeline_cmdsplitparts_coverage_test.cpp (rigged round-trip: multi-submesh + tris + skeleton + names + unit-length normals). Slice D (#863) — Solidify (SubMeshOps::solidify, SplitOptions::solidifyParts): thin-shell game assets are single-sided surfaces with no wall thickness, so an exploded part exposes its hollow interior at the cut. solidify offsets an INNER copy of the surface inward by a thickness (auto ≈1.5% of the AABB diagonal) along area-weighted vertex normals, reverses its winding, and stitches a wall between every open boundary edge and its inner counterpart (wall loop b→a→ai→bi cancels both the outer a→b and the reverse-wound inner dangling edges → watertight). Turns each part into a closed slab AND seals it. Opt-in: GUI "Solidify thin shells" checkbox in the Split section → SplitMeshCommand solidify param → SplitOptions::solidifyParts; CLI segment --split-parts --solidify; MCP split_mesh_by_segments {solidify:true}. Verified on Hip Hop Dancing.obj: each part ~2× verts, 0 welded open edges. Solidify winding gotcha: inner shell is reverse-wound so the wall must cancel BOTH the outer boundary edge (needs b→a) and the inner dangling edge (needs ai→bi). (Two other #863 sub-features were built and REMOVED — see the parenthetical at the top of this entry: (1) the 3D-print alignment PEGS (unreliable on organic joints), and (2) capOpenBoundaries/capParts cut-face capping + the explode "Cap open boundaries" toggle — capping a cut RING is watertight but a thin game-shell still looks hollow at the cut, and a recessed-rim attempt made artifacts, so cap was dropped in favour of solidify.) Slice E (#864, done): CLI --explode-parts + MCP explode_mesh_parts/join_mesh_parts (see the explode/join clause above). Slice F (#865): docs (this entry + README + docs/PART_OPS.md), UI tooltips, breadcrumbs (all mesh.parts.* present), headless-safe command tests.
  • Image-to-3D (TripoSR) (src/ImageTo3D/, epic #764): single-image → 3D mesh generation via TripoSR (Tripo AI + Stability AI, MIT code AND MIT weights, HF stabilityai/TripoSR). The fifth ONNX consumer (after #404/#408/#409/#410); all files live in the src/ImageTo3D/ feature folder. MIT code+weights is the deciding factor for redistribution (Homebrew/Snap/WinGet/Docker) — the bar UniRig #408 cleared and non-commercial SF3D failed. MeshGenPredictor (Ogre-free + unit-tested) runs two exported ONNX graphs — encoder image[1,3,512,512]→scene_codes[1,3,40,64,64] (triplane) and per-point decoder scene_codes+points[1,P,3]→density[1,P,1],color[1,P,3] — GENERATING query points per chunk (not the whole res³ grid up front — that would OOM at 512) and extracting the surface with MarchingCubes (native Lorensen impl, public-domain tables, zero deps; TripoSR's torchmcubes is torch/GPU-only). Surface = MC on density − threshold at iso 0 (threshold 25.0, radius 0.87); our MC is inside-positive so extract() emits v0,v2,v1 (flipped winding) to keep faces OUTWARD (else the mesh renders inside-out). Model size tiers (MeshGenPredictor::Quality {Fp32,Int8} → triposr_encoder{,_int8}.onnx): fp32 ~1.68 GB (best), int8 ~430 MB (slight quality loss); user-selectable, downloads on demand. (fp16 was dropped — TripoSR's attention has a hardcoded Cast-to-float32 the ONNX fp16 converters can't rewrite; int8 is smaller anyway.) MeshGenBuilder (the ONLY Ogre-touching piece) turns the arrays into an Ogre::Mesh (POSITION + accumulated per-vertex NORMAL + optional DIFFUSE VET_COLOUR with a lit vertex-color material; 16-/32-bit index by vertex count; validates index data first), bakes -90°X + +90°Y into positions+normals so the model stands upright and faces forward, uses a UNIQUE per-call node/mesh name, and returns the SceneNode for export. Background removal: BackgroundRemover (6th ONNX consumer) runs U²-Net (Apache-2.0, rembg's model) to isolate the subject: [1,3,320,320]→[1,1,320,320] saliency, then composites over gray 128 (not white — white → a reconstructed wall) and crops/re-pads to the subject at 0.85 foreground ratio (TripoSR's resize_foreground). Model ai_models/rembg/u2net.onnx (QTMESH_REMBG_MODEL_BASE_URL/ai/rembgModelBaseUrl; guard QTMESH_REMBG_NO_DOWNLOAD); falls back to the raw image if unavailable. Everything ENABLE_ONNX-guarded; no fallback (generative), so a non-ONNX build / missing model returns a clear error (never crashes). Models under ai_models/triposr/ download on first use (ensureModelBlocking(q); QTMESH_TRIPOSR_MODEL_BASE_URL/ai/triposrModelBaseUrl; guard QTMESH_TRIPOSR_NO_DOWNLOAD), OR can be pre-downloaded from the AI Settings modal's Download tab (tier picker + progress bar). Export is scripts/export-triposr-onnx.py (offline, not shipped; transformers==4.35.0, torchmcubes stub, frozen ViT pos-encoding; emits the int8 variant unless --no-quant — see docs/IMAGE_TO_3D_SPIKE_764.md). Surfaced via CLI qtmesh generate3d <image> [-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.<i> 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.
  • ExportOptimizer (src/ExportOptimizer.h/cpp, issue #399): Pipeline that runs meshopt_optimizeVertexCache → meshopt_optimizeOverdraw (threshold 1.05) → meshopt_optimizeVertexFetchRemap on every submesh of an entity. Surfaced through the Inspector validation flow — the "Optimize Geometry (cache + overdraw + fetch)" button in PropertiesPanel.qml runs it via MeshValidator::optimizeVertexCache. NOT hooked into MeshImporterExporter::exporter by default (an earlier draft did this and crashed on macOS during a normal export — silent buffer mutation during export is dangerous; explicit user invocation via the validation button is safer). Vertex-fetch is skipped when the submesh uses useSharedVertices since remapping shared verts would scramble other submeshes' indices. qtmesh info --json includes submeshAcmr[] per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb category ai.assist.optimize_export.
  • MotionInbetween (src/MotionInbetween.h/cpp, issue #409): AI animation in-betweening — fills the gap between two sparse keyframes with smooth, plausible intermediate poses. The issue proposes Robust Motion In-betweening (Harvey et al., Ubisoft, SIGGRAPH 2020), a small transition transformer; like #404/#408 the ML path runs on ONNX Runtime (#ifdef ENABLE_ONNX) and is the third ONNX consumer. The spline fallback is first-class (per the issue's acceptance criteria): interpolateSpline (cubic-Hermite with Catmull-Rom tangents for translation/scale, shortest-arc slerpQuat for rotation) is Ogre-free, always compiled, and used automatically whenever the binary lacks ONNX, the model is missing/un-downloadable, the skeleton is incompatible with the model, or the run fails — Result::usedModel + fallbackReason tell the caller which path ran. The core works on flat per-frame pose arrays (channels = bones × 10 DoF: [tx,ty,tz, qx,qy,qz,qw, sx,sy,sz]) with a Channel layout (Scalar/QuatStart/QuatCont) so it's unit-tested without Ogre/GL. MotionInbetween::ensureModelBlocking() downloads rmib.onnx on first use to AppData/ai_models/inbetween/ (override QTMESH_INBETWEEN_MODEL_BASE_URL / QSettings ai/inbetweenModelBaseUrl; offline guard QTMESH_INBETWEEN_NO_DOWNLOAD) — the #408 self-contained pattern, with the #ifndef ENABLE_ONNX return {} guard. AnimationMerger::inbetweenAnimation(skel, animName, t0, t1, gapFrames, modelPath, forceFallback) is the Ogre adapter: it packs every bracketing node track's start/end pose into ONE predict() call (so the model sees the full skeleton), scatters the predicted per-frame poses back as keyframes at uniform interior times, and returns an InbetweenResult (keyframesInserted / tracksAffected / usedModel / fallbackReason). Surfaced via CLI qtmesh anim <file> --in-between --gap-frames N [--start-time S] [--end-time S] [--no-model] [--animation NAME] [-o out] (CLIPipeline::cmdAnim), the MCP motion_in_between tool (MCPServer::toolMotionInBetween, args {gap_frames, entity_name?, animation_name?, start_time?, end_time?, no_model?}, registered heavy), and the dope sheet "AI in-between … Fill gap" control (qml/AnimationDopeSheet.qml → AnimationControlController::inbetweenWindow, shown when the selection spans a time window; emits inbetweenStatus). Sentry breadcrumb category ai.assist.in_between. Canonical skeleton + retargeting: the model is trained on a FIXED 22-joint CMU core-body skeleton (C=220), so AnimationMerger::inbetweenAnimation maps the entity's track bones onto those 22 roles via MotionInbetween::canonicalIndexForBone() (handles Mixamo mixamorig:*, generic L_Shoulder, and CMU names; rejects finger/toe/face bones; note Mixamo "Shoulder"=clavicle→collar while "Arm"=upper-arm→the CMU shoulder role). When a strong majority (≥¾) of the 22 roles resolve it packs the canonical pose, runs the model, and scatters predictions back to the matched tracks; unmatched/non-bracketed tracks (and rigs that don't resolve enough roles, and non-ONNX builds) use the per-track spline. Model: ours, trained from scratch on CMU MoCap (scripts/export-rmib-onnx.py, one-time offline dev tool — NOT shipped) — CMU is permissively licensed (commercial-OK), unlike the field-standard LAFAN1 (CC-BY-NC-ND, rejected). Validated: rotation error < half of slerp on held-out CMU motion. Hosting: rmib.onnx (~13 MB) is live in the fernandotonon/QtMeshEditor-models HF repo under inbetween/, downloads on first use (see THIRD_PARTY_AI_MODELS.md).
  • MotionLibrary / text-to-motion (src/MotionLibrary.h/cpp, issue #411, experimental): generate a skeletal animation from a text prompt. The #411 spike (see docs/TEXT_TO_MOTION_SPIKE_411.md) proved a from-scratch GENERATIVE model (MDM-style) collapses to a static pose without multi-day ML effort, and all off-the-shelf models (MDM/T2M-GPT/MotionGPT) train on AMASS-derived HumanML3D/KIT-ML = non-commercial (the LAFAN1/ShapeNet wall again). So the SHIPPED feature is a template-clip MVP: a curated library of permissive CMU MoCap clips (commercial-OK, same source as #409 RMIB), matched to the prompt by action keyword + synonyms (MotionLibrary::matchPrompt), then retargeted onto the user's rig via AnimationMerger::applyMotionClip → MotionInbetween::canonicalIndexForBone (the SAME 22-joint canonical mapping as #409). MotionLibrary is Ogre-free + unit-tested (MotionLibrary_test.cpp): parses qtmesh-motion-library-v1/v2 JSON (per-frame, per-joint canonical quats; v2 adds an optional 22-entry cmuRestWorld block) + keyword matching. Library downloads on first use to AppData/ai_models/motion/ (override QTMESH_MOTION_LIBRARY_BASE_URL / QSettings ai/motionLibraryBaseUrl; offline guard QTMESH_MOTION_NO_DOWNLOAD) — built offline by scripts/build-motion-library.py (10 actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle, ~0.9 MB), hosted on the fernandotonon/QtMeshEditor-models HF repo under motion/. Retarget (applyMotionClip) — the part that makes it look right: the CMU clip stores each joint's LOCAL (parent-relative) rotation with rest ≈ identity (the rest DIRECTION is in the BVH bone offsets, captured as the v2 cmuRestWorld per-joint world-rest Wcmu). The exact per-bone formula is local(f) = parentWorld⁻¹ · (Wcmu · clip(f) · Wcmu⁻¹) · parentWorld · bind, where bind = the rig's STANDING pose harvested from frame-0 of its existing animation (Mixamo bone rest is identity — the standing pose lives in the anim, NOT in getInitial{Orientation,Position} which is inflated and would stretch the mesh), and the root (hip) is locked to the standing pose (CMU bakes whole-body facing into the root). The Wcmu·clip·Wcmu⁻¹ conjugation is the CMU↔target change-of-basis that cancels the per-bone ROLL twist between rigs with different bone axes (Mixamo arms point down their length / sideways; UniRig is axis-aligned). v1 libraries (no cmuRestWorld) fall back to Wcmu=identity (parent-world transport only — direction-correct, residual roll). Writes rotation-only keyframes (translation/scale stay at the standing pose, preserving rig proportions) and requires ≥½ of the 22 canonical roles to resolve (else fails — not a humanoid rig). Render-verified on the Rumba (Mixamo) rig via the isometric loop: walk = upright stride with arms hanging+swinging; wave = upright + natural. Surfaced via CLI qtmesh anim <file> --generate "<prompt>" [--duration N] [-o out] (CLIPipeline::cmdAnimGenerate), the MCP generate_motion tool (MCPServer::toolGenerateMotion, args {prompt, entity_name?, duration?, output_path?}, registered heavy), and the Animation panel "Generate from text" control (qml/AnimationControlPanel.qml → AnimationControlController::generateMotion, emits generateMotionStatus). Sentry breadcrumb ai.assist.text_to_motion. v4 library (July 2026): clips are the trial's ACTIVE window (max motion energy, start snapped to a calm near-neutral frame — the retarget deltas against clip frame 0), replacing first-4s slices that mostly captured idle lead-ins; 13 actions (adds sit/throw/boxing; 'idle' now a real wait trial — the old 69_01 source walked; 'dance' is salsa — ballet pirouettes fold under the locked root). #1034 — the model path is SINGLE-STEP ONLY. MotionGenerator::generate consumes the whole prompt as one text condition and emits ONE clip, so a sequenced prompt ("walk then punch, wave twice then sit") came back as a single averaged pose — every action at once — because MotionComposer is reachable only on the template path. All three surfaces now check MotionComposer::promptHasMultipleSteps (a library-free segmenter sharing parse's connectives, so the two cannot disagree about where a prompt divides) and route a multi-step prompt to the template library with an explicit message (GUI status line / CLI stderr note / MCP model_skipped). A single action — including one with a repeat or duration ("wave twice") — still uses the model. Per-step model generation stitched by the composer is the better long-term answer, but the model's per-step quality is below the template library's, so routing is the right trade today. --model may appear anywhere in the argument list (it used to be honoured only AFTER --generate, so --model --generate "..." silently ran the template path); --model without --generate is now an explicit error (exit 2).

Generative path (opt-in --model / model:true / GUI checkbox): MotionGenerator + motion/t2m.onnx — a CVAE transformer trained from scratch on the same CMU source (scripts/prep-t2m-v4.py + scripts/train-t2m-onnx-v4.py, offline): 30fps WORLD-frame windows w/ neutral starts (the v3 model trained on raw-120fps 0.33s local-frame windows and folded/flailed at 3.5x real velocity), absolute-pose decoder (no error-accumulating delta-cumsum), per-sample + rotation-space (geodesic) velocity matching, derived-local supervision (parent^-1*child — the exact quantity applyMotionClip renders; world-only losses let spine-chain errors stack into a visible fold), and z=0 latent-dropout supervision (the app infers with seed=zeros; an unsupervised z=0 is out-of-distribution for a low-beta CVAE). The vocab json declares frame:world + fps, read by MotionGenerator::generate → Result::worldFrame → applyMotionClip, so model clips ride the same world retarget as v3 template clips. Template library stays the default + automatic fallback. Quality limit: the model's z=0 output is smooth/upright but gentler than real clips (conditional-mean effect; the medoid-exemplar alternative is crisper numerically but renders twisted — --z0-target flag documents both); the template path is the quality bar. v5 library (#838, July 2026): replaces the 47-clip CMU-only set with a CC0/CC-BY corpus scraped from Sketchfab + OpenGameArt + Quaternius packs (scripts/scrape-motion-corpus.py → --sketchfab/--opengameart/--packs, CC0/CC-BY only, per-asset provenance in manifest.json + CC-BY credits in ATTRIBUTION.md that MUST ship with the library). scripts/build-motion-library-v5.py runs qtmesh anim --dump-canonical per asset (cached *.canonical.json), gates each clip (--min-roles 14 humanoid gate + #855 clip_quality: bind-frame + animated + mid-clip topple uprightness gates — spine_up_min < −0.25 drops ground/fall clips that average upright but pitch head-down mid-motion, e.g. a "kick to the groin" that flops horizontal; HORIZONTAL_OK={death,roll,crawl,swim,fall,sleep} exempt — energy band, placeholder-arm), dedups (quat fingerprint + semantic asset+anim+length), caps --max-per-action 12, and canonicalises verbatim action labels (CANON_ACTION: waving→wave, dying→death, …). Result: 115 clips across 23 actions (walk/run/punch/jump 12 each; +death/attack/shake/crawl/roll/pray/pickup/swim/fly the CMU set lacked), render-verified on Rumba. New actions get runtime prompt routing via extended kSynonyms in MotionLibrary.cpp (die→death, grab→pickup, dodge→roll, …). Published to the CC0 QtMeshEditor-t2m HF repo (mirrored into QtMeshEditor-models via scripts/sync-hf-model-repos.sh). The schema is still qtmesh-motion-library-v3 — v4/v5 are BUILD generations, not schema versions.

  • Arm-space post-process (AnimationMerger::adjustArmSpace, issue #854): Mixamo-style "Character Arm-Space" — swing the arm chains outward (widen) or inward (tuck) on ANY animation (not just generated ones) to rescue arm-into-torso clipping / too-wide arms on rigs whose proportions differ from the source. Rewrites ONLY the shoulder (canonical 7/11) + collar (6/10, fractional) keyframes; elbows/hands follow through the hierarchy, legs/spine untouched. The swing is about the torso FORWARD axis (from the target bind frame Ct, reusing the retarget's readTargetBindFrame helper), mirrored per side so +deg widens both arms. Keyframes are deltas on the bind pose (Ogre's NodeAnimationTrack::applyToNode post-multiplies onto the reset bone), so the world swing S is folded into each keyframe as L·kf where L = Wbind⁻¹·S·Wbind (conjugation into the bone's bind-local frame). Gotcha: TransformKeyFrame::setRotation does NOT invalidate the track's interpolation caches, so after editing keyframes you MUST call track->_keyFrameDataChanged() or the next apply() replays the pre-edit rotations (edits appear to lag one call — masked in the live GUI by the render loop's continuous re-apply, but deterministic single evaluations get the stale pose). Absolute + idempotent: the last-applied angle is tracked PER SKELETON INSTANCE on bone[0]'s UserObjectBindings (key qtme.armspace.<anim>) — NOT a process-global map (that pollutes across entities AND across tests sharing a process, which is exactly how the first cut regressed); NOT persisted to disk (export bakes the final keyframes). Each call reverts the stored angle first (delta = new−stored). So the angle is ABSOLUTE: +45 leaves the clip at +45, a later −45 leaves it at −45 (net = −45), and only adjustArmSpace(0) restores the base pose bit-near-exactly. currentArmSpace(skel, anim) exposes the stored value so the GUI seeds its slider with the clip's real state. applyMotionClip erases the entry when it (re)creates a generated_* clip so a regenerated clip starts unadjusted. migrateArmSpaceKey moves the entry on rename — called from BOTH AnimationMerger::renameAnimation (CLI/merge) and SkeletonTransform::renameAnimation (GUI) so a renamed widened clip keeps its value. Surfaced via CLI qtmesh anim <file> --generate "<p>" --arm-space <deg> and standalone qtmesh anim <file> --arm-space <deg> --animation <name> -o out (CLIPipeline::cmdAnim/cmdAnimGenerate), MCP arm_space arg on generate_motion (response echoes arm_space_applied) + standalone adjust_arm_space tool (MCPServer::toolAdjustArmSpace — edits the mesh's MASTER skeleton so output_path export includes the change), and the Inspector Animations section: a live "Arm space" slider (−30…+45°) plus a per-row ↔ button that targets any clip (qml/PropertiesPanel.qml → AnimationControlController::adjustArmSpace/currentArmSpace). The slider updates the viewport LIVE while dragging, even when the clip is PAUSED (_notifyDirty + re-stamp the state's time); generation never bakes the slider value in. Unit-tested in AnimationMerger_test.cpp (swing angle, mirrored per-side direction, absolute/idempotent via currentArmSpace, non-arm invariance, rename migration). Sentry breadcrumbs: ui.action (GUI) / ai.tool_call (CLI + MCP). The same mechanism is the door for future motion-amplitude / hip-sway / stance-width knobs.
  • World-facing metric (CLIPipeline facingMode, issue #837 follow-up): a generated/retargeted clip walks or runs the "wrong way" partly because of a camera convention — clips face +Z (the Mixamo/GLTF default forward), which is away from a default viewport camera looking toward +Z. The read-only metric qtmesh anim <file> --facing --animation <name> plays the clip, samples the hip's world-forward (fwd = left×up from its bind frame) averaged over 30 samples, and prints +Z / −Z (also --apply-canonical <clips.json>, a self-retarget round-trip parity harness). These stay as diagnostics for iterating the retarget/library. A flipAnimationFacing 180°-turn toggle (CLI --flip-facing, MCP flip_facing, an Inspector ⟳ button) was built and then REMOVED — turning the whole skeleton rigidly just made the clip face the other way while still moving wrong (the walk/run/sit issue is per-clip retarget bending, not a global facing flip), so the toggle didn't help and was cut. An auto-flip-on-generate default was likewise tried and reverted. The remaining walk/run/sit quality problem is being tracked as a data/retarget issue (likely specific corpus clips bending the wrong way); the good actions (working/strafe/shake/pickup) are the quality bar.
  • VAT export family (src/VATBaker.{h,cpp}, src/VATBakerController.{h,cpp}, src/VATShaderEmitter.{h,cpp}, #371 → #522): Vertex Animation Texture baker in the OpenVAT layout (sharpen3d/openvat — <base>_pos.png|exr, height = 2×frames, top half positions normalized to the sidecar's outward-0.1-rounded os-remap Min/Max, bottom half (n+1)/2 normals; the reference Godot/Unity/Unreal/Blender shaders consume it unmodified). Four samplers, one baker (VATBaker::Mode): Skeletal (the original: enable the skeletal state, read _getSkelAnimVertexData()), MeshAnim (a mesh animation with vertex tracks — Alembic VAT_POSE stream), Morph (the morph weight clip, default MorphAnimationManager::kWeightClipName; sidecar lists _morph_targets), Rigid (chunk = submesh; per frame a Horn closed-form fit — FaceCapPose::solve, reused from mocap, compiled unconditionally — of the chunk's deformed vertices against its bind pose; texture width = chunk count, top half = per-frame pivot position, bottom half = quaternion (q+1)/2 in RGBA with hemisphere continuity; p' = q*(p−pivot)+pivot_frame; per-chunk maxResidual in the sidecar says whether the source really moves rigidly). One deformed-buffer resolver (deformedVertexData): skinned entity → skel buffer (Ogre chains morph→skin, and with every skeletal state disabled the skin stage is identity, so morph/mesh-anim on a rigged mesh still work); unskinned + VAT_* submesh → _getSoftwareVertexAnimVertexData(); else the static buffer. Needs addSoftwareAnimationRequest(true) AND the _notifyDirty + _fireFrameRenderingQueued bumps per frame (without them every row equals row 0). Rigid refuses shared vertex data (a chunk must own its range). Encodings bitDepth 8/16/32 (rgba8/rgba16/exr; 8-bit is re-quantized from the 16-bit pack so both decode against the same bounds; rigid EXR is RGBA via MinimalEXR::writeRGBA32F). target (agnostic|unity|unreal|godot) is recorded as _target; the texture is identical per target — the engine template applies the axis swizzle on read — and a non-agnostic target ships that template (VATShaderEmitter::writeShaders(dir, engines, rigidMode); rigid mode ships openvat_rigid.gdshader only, never the per-vertex shader, which would misread a chunk texture). Surfaces: CLI qtmesh vat (--mode/--encoding/--target, mode-aware entity pick, rigid --emit-uv2 writes the chunk column into UV2.x via emitGltfUv2's columnOverride), MCP bake_vat (mode/encoding/target/include_shaders; anim optional for morph), Inspector Animation-mode "VAT" section (Mode picker from VATBakerController::availableModes → animationsForMode, Encoding + Target pickers). Breadcrumbs file.export carry vat_mode=<id>. Two bugs found by the ICT lipsync parity test (fixed): (1) Ogre wraps AnimationState::setTimePosition(t) with fmod(t, length) while the state LOOPS (the default), so the last baked frame at t == length silently read frame 0 — invisible on a looping walk, wrong mouth on a lipsync clip; the baker now bakes with loop off and restores the flag; (2) readGltfVertices refused the whole glTF when ANY buffer was a base64 data: URI (the exporter appends the morph-weights animation as one), so every morph bake lost its vertex alignment + UV2; it now decodes data URIs and reports the failing gate in the warning. Verified: the baked 16-bit texture decodes to within one quantization step (0.00036 vs 0.00052) of an independent glTF blend-shape oracle on every frame of the 51-target ICT lipsync clip (scratchpad/vat_parity.py-style check: base + Σw·target from the glTF weights sampler). Tests: VATBaker_test.cpp (in-memory morph / vertex-cache / two-chunk rigid fixtures decode the PNG back and check the motion; the rigid fixture must fit with residual < 1e-3), CLIPipeline_cmdvat_coverage_test.cpp, MCPServerBakeVat_coverage_test.cpp, VATBakerController_test.cpp. Docs: tools/vat-shaders/README.md (modes table, rigid layout, per-engine quaternion swizzles).
  • Isometric sprite export (src/ModelIsometricRenderer.h/cpp, epic #724): headless RTT renderer for 8-direction (configurable) isometric sprite atlases. Reuses the turntable's offscreen capture pattern (RTSS materials, stable orbit framing from rest bounds, single camera re-placed per direction). Outer loop = compass directions (row 0 = front/+Z, clockwise from above); inner loop = evenly spaced animation frames via AnimationState::setTimePosition + _updateAnimation before readback. Grid layout: rows = directions, columns = frames. Options include --elevation / --camera-height, --resolution, --camera-distance, and --padding (auto-fit multiplier). Editor grid and non-export scene entities are hidden during capture. Surfaced via qtmesh isometric, MCP generate_isometric_sprites, and Animation Mode → Mode Tools → "Export Isometric Sprites…" (qml/IsometricSpritesDialog.qml, IsometricSpritesController). Sentry breadcrumb categories file.export / ai.tool_call.
  • FBX LOD export gotcha: FBXExporter prefers the cached qtme.faces.<i> n-gon binding (set up by quad-migration #326) over SubMesh::indexData. The CLI lod per-LOD export path in CLIPipeline::cmdLod temporarily erases those bindings (and restores them after) so the swapped-in LOD indices actually reach the wire. If you add another LOD-export entry point, mirror that erase/restore pair.

Development Guidelines

  • UI: QML over Widgets. New UI should be built in QML (Qt Quick), not Qt Widgets. The Inspector panel (qml/PropertiesPanel.qml) and Material Editor (qml/MaterialEditorWindow.qml) are the reference for the QML approach. The old Transform/Material/Edit/Animation tabs have been replaced by the QML Inspector. AnimationWidget and PrimitivesWidget still exist as hidden backing widgets but are not user-visible tabs.
  • Cross-platform: Windows, Linux (Ubuntu), macOS. All code must compile and run on all three. Guard platform-specific APIs with #ifdef Q_OS_WIN, #ifdef Q_OS_MACOS, #ifdef Q_OS_LINUX. Test the CI build across all three platforms before merging.
  • Sentry breadcrumbs. All user-facing actions and significant operations must be tracked with SentryReporter::addBreadcrumb(category, message). Use "ui.action" for toolbar/menu clicks, "ai.tool_call" for MCP tool invocations, "file.import" / "file.export" for I/O operations. This enables crash diagnostics and usage telemetry. Check existing patterns in mainwindow.cpp, TransformOperator.cpp, and MCPServer.cpp.
  • Unit tests. Add Google Test unit tests for new functionality. Test files live alongside source in src/ with the _test.cpp suffix (e.g., Manager_test.cpp). CI runs tests only on Linux to save budget, so:
    • Features that depend on optional components (e.g., local LLM / llama.cpp) may not be available in the test environment — guard with #ifdef ENABLE_LOCAL_LLM or skip gracefully.
    • Tests must work under Xvfb (headless X11) — avoid assumptions about a real display.

Platform-Specific Notes

  • macOS: macBundlePath() returns .app bundle root (not Contents/MacOS/). Ogre resource paths in resources.cfg resolve relative to this. AGL framework stub created at configure time for newer macOS.
  • macOS Homebrew CLI symlink: Homebrew installs /opt/homebrew/bin/qtmesheditor as a SYMLINK straight to the binary inside Contents/MacOS. Launched that way, Qt resolves applicationDirPath() to the symlink dir (/opt/homebrew/bin), never finds Contents/Resources/qt.conf, and can't locate the bundled Qt plugins / QML modules under Contents/PlugIns → the GUI opens but every QQuickWidget (Inspector, mode bar, Context panel) renders BLANK WHITE. Double-click / open works (Launch Services supplies the bundle context). main.cpp::fixBundlePathsForSymlinkLaunch(argv[0]) (macOS-only, runs before QApplication) follows the symlink to the real binary, and if it lives in …/X.app/Contents/MacOS sets QT_PLUGIN_PATH + QML_IMPORT_PATH/QML2_IMPORT_PATH to the bundle's PlugIns / PlugIns/qml; the post-QApplication block also addLibraryPaths them. NB testing this by hot-swapping the binary in an installed .app breaks its code signature (macOS then refuses to run it) — test with a freshly built+signed bundle, not an in-place binary swap.
  • Windows: MinGW build. <execinfo.h> (backtrace, backtrace_symbols_fd) and <unistd.h> (dup, STDERR_FILENO, SIGBUS) are unavailable — guard with #ifndef Q_OS_WIN.
  • Linux: Requires Xvfb for headless Qt testing in CI.

Ogre API Pitfalls

  • Ogre::MaterialSerializer::parseScript() does not exist in Ogre 14.x. Use MaterialManager::getSingleton().create(name, group) and set properties via the API.
  • To serialize a material to string: serializer.queueForExport(mat) then serializer.getQueuedAsString().
  • Manager::getEntities() returns all attached objects — you must check obj->getMovableType() == "Entity" before casting to Ogre::Entity* (ManualObjects will crash otherwise).

Versioning

The single source of truth for the application version is in CMakeLists.txt:

project(QtMeshEditor VERSION X.Y.Z LANGUAGES CXX)

All other version references are auto-generated from this via CMake template substitution (@PROJECT_VERSION@):

  • src/Info.plist.in — macOS bundle Info.plist
  • cfg/version.txt.in — runtime version file
  • DEBIAN-control.in — Debian package control file

To bump the version, only edit the VERSION in CMakeLists.txt line 16. The rest updates automatically on rebuild.

Version format: X.Y.Z only — never prepend v. GitHub release tags, update check comparisons, and all version strings use plain X.Y.Z (e.g., 3.0.0, not v3.0.0). The update check feature compares the runtime version against the latest GitHub release tag, so a v prefix would break the comparison.

Pinned CI doc examples: After changing project(QtMeshEditor VERSION …), run ./scripts/sync-doc-versions-from-cmake.sh so README.md and website/src/hooks/useQtmeshActionRef.js stay aligned. CI runs ./scripts/sync-doc-versions-from-cmake.sh --check in the verify-doc-versions job.

Note: MCPServer.h has a separate SERVER_VERSION ("1.0.0") for the MCP protocol — only bump that if the MCP interface changes.

Docker

A multi-arch Docker image (linux/amd64 + linux/arm64) is published on each release to both ghcr.io/fernandotonon/qtmesh and fernandotr1/qtmesh (Docker Hub). Because it ships a native linux/arm64 variant, it runs natively on Apple Silicon Macs (and ARM servers) as well as Intel/x64 — docker run auto-selects the host arch from the manifest, no QEMU.

Run via Docker:

docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh info model.fbx --json
docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh convert model.fbx -o model.gltf2

Key files:

  • Dockerfile — Ubuntu 24.04 base. Multi-arch via ARG TARGETARCH → COPY qtmesheditor_${TARGETARCH}.deb, so each platform in the buildx manifest installs its own .deb. Xvfb for headless GL. Build context must contain both qtmesheditor_amd64.deb and qtmesheditor_arm64.deb.
  • docker-entrypoint.sh — Starts Xvfb, routes CLI commands via --cli flag, MCP commands to qtmesheditor
  • .github/workflows/deploy.yml — the release docker-publish job downloads both per-arch .debs (linux-binaries-amd64 + linux-binaries-arm64 artifacts from the build-linux matrix) and docker buildx build --platform linux/amd64,linux/arm64 pushes one multi-arch manifest.
  • .github/workflows/docker-publish.yml — Manual workflow_dispatch; downloads both release .debs (falls back to amd64-only for old releases without an arm64 .deb).
  • .github/actions/qtmesh/action.yml — Reusable composite action for CI/CD pipelines

Multi-arch build pipeline (#): the arm64 .deb.

  • build-linux (and its build-n-cache-assimp-linux / build-n-cache-ogre-linux dependencies) is a strategy.matrix over {amd64: ubuntu-latest, arm64: ubuntu-24.04-arm} — arm64 builds on GitHub's native arm64 runner (no QEMU), producing qtmesheditor_arm64.deb. Qt for arm64 is arch: linux_gcc_arm64 (install path gcc_arm64); the multiarch lib triplet is aarch64-linux-gnu. Cache keys embed matrix.arch (runner.os is Linux for both, so without it the two arches would clobber each other's assimp/ogre caches). The packaging seds the .deb control Architecture: field to match.
  • Consumers pinned to one arch: unit-tests-linux restores the -amd64- caches; snap-publish downloads linux-binaries-amd64 (Snap stays amd64-only). ONNX Runtime downloads a per-platform archive (cmake/OnnxRuntime.cmake) — the arm64 build pulls the aarch64 ORT.

Notes:

  • The entrypoint passes --cli explicitly because the launcher script's exec changes argv[0] to contain "editor", which breaks CLI mode detection by binary name.
  • The Docker base must be Ubuntu 24.04 (not 22.04) to match the CI build runner's GLIBC version.

WinGet (Windows Package Manager)

QtMeshEditor is available via WinGet: winget install FernandoTonon.QtMeshEditor.

Key files:

  • winget/manifests/f/FernandoTonon/QtMeshEditor/ — local copy of the WinGet manifest (version, locale, installer YAML)
  • scripts/update-winget.sh — generates updated manifest files for a new release
  • .github/workflows/deploy.yml — winget-publish job auto-submits to microsoft/winget-pkgs on release

Updating for new release: The winget-publish CI job uses wingetcreate --submit to automatically submit a PR to microsoft/winget-pkgs when a GitHub Release is published. Requires a WINGET_TOKEN secret (GitHub PAT with public_repo scope). Do not use the git database API (blobs/trees/commits endpoints) — that requires repo scope. wingetcreate --submit uses the Contents API which works with public_repo.

Manual alternative: ./scripts/update-winget.sh <version> generates the manifest locally.

GitHub Action (Marketplace)

The qtmesh CLI is published as a GitHub Action on the GitHub Actions Marketplace. The action.yml lives at the repo root.

- uses: fernandotonon/QtMeshEditor@v1
  with:
    command: scan
    input-file: ./assets
    options: --fail-on warning

Key files:

  • action.yml — root-level action definition (required for marketplace)
  • .github/actions/qtmesh/action.yml — legacy local action (kept for backward compatibility)
  • Docker image: ghcr.io/fernandotonon/qtmesh (built from this repo on each release)
  • Redirect repo: fernandotonon/qtmesh points users to this repo

When to update action.yml:

  • New CLI subcommand added → update command description
  • Subcommand flags change → update options description
  • Docker image name/registry changes → update the docker run command
  • No update needed for: bug fixes, GUI changes, MCP tools, or features that don't change CLI interface

The action uses image-tag: latest by default, so users automatically get fixes without version bumps. For reproducible CI, pin both uses: fernandotonon/QtMeshEditor@X.Y.Z and image-tag: 'X.Y.Z' to the same semver as CMakeLists.txt (kept in sync via scripts/sync-doc-versions-from-cmake.sh).

Marketplace publishing: When creating a GitHub Release, check "Publish this Action to the GitHub Marketplace". The v1 tag should be kept pointing to the latest stable commit (force-push tag on each release).

CI/CD

ccache on unit-tests-linux (measured 2026-09-18): a per-run key saved from a PR is a write-only cache. The job reported Hits: 0/1442 (0.00%) on every run while claiming 97.7% of calls were cacheable, and the build-wrapper step ran >30 min cold every time. Two compounding causes: (1) GitHub Actions caches are BRANCH-SCOPED — a run reads entries written by its own ref or by the DEFAULT branch, never by another PR's ref, so a ${{ github.run_id }} key saved from refs/pull/N/merge was readable by nothing; (2) quota eviction — those private ~840 MB entries (12 of them) filled the repo's 10 GB allowance, and GitHub evicts by age, so the one refs/heads/master entry every PR needed as its base was deleted too. The log line to look for is No cache found. in the ccache-action step (the Assimp/Ogre actions/cache steps restore fine, which makes it easy to assume ccache did too). And a third, which was the one actually keeping the hit rate at 0 — read the ACTION'S SOURCE, not its README. hendrikmuhs/ccache-action rewrites BOTH sides of what you pass (dist/restore/index.js):

const keyPrefix   = ccacheVariant + "-";                        // "ccache-"
const restoreKeys = inputs.restoreKeys.map(k => keyPrefix + k + "-");

It prepends ccache- and appends -, so the string actually looked up is ccache-<yours>-, while entries are saved as ccache-sonar-tests-Linux-<run_id>-<timestamp>. That makes two natural-looking values both wrong:

restore-keys: value actually looked up matches
sonar-tests-Linux- (original) ccache-sonar-tests-Linux-- ✗ double dash
ccache-sonar-tests-Linux- (a "fix" that made it worse) ccache-ccache-sonar-tests-Linux-- ✗ doubled prefix
sonar-tests-Linux (no trailing dash, no prefix) ccache-sonar-tests-Linux- ✓
The lesson: a cache that reports No cache found. while entries plainly exist is a KEY-COMPOSITION bug — print what the action composes (or read its source) instead of guessing at the prefix, and verify with the table above rather than by shipping another guess. (The action also appends a timestamp to the primary key, so the exact key can never match on a later run — prefix restore is the ONLY path to a hit, which makes a wrong restore key fatal rather than merely suboptimal.) Fix: save: ${{ github.ref == 'refs/heads/master' }} — only the default branch WRITES, every run restores from the master lineage via restore-keys: sonar-tests-${{ runner.os }}, so PR builds are read-only consumers of one warm shared entry. Inspect with gh api repos/<o>/<r>/actions/caches (the ref field is the scope) and .../actions/cache/usage; note the usage endpoint is cached for a while after deletions, so sum the entries themselves to confirm.

GitHub Actions workflow in .github/workflows/deploy.yml builds for Windows (MinGW), macOS, and Linux. Tests run on Linux with SonarCloud coverage. Releases auto-update the Homebrew cask, WinGet package, Snap Store, and Docker image. A scan-assets-docker job runs the fernandotonon/qtmesh action on the repo's own test assets to validate the Docker image and scan pipeline on every push/PR.