This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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=arm64Build:
cmake --build build_local --target QtMeshEditor -j4Run (macOS):
./build_local/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditorBuild 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 suiteRun 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/httpTokenCLI 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.gltfCLI 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.
- 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 vendoredcontrib/dracoas a standalone static lib (Assimp's own-DASSIMP_BUILD_DRACOintegration 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
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. EmitssceneNodeCreated,entityCreated,sceneNodeDestroyedsignals. - SelectionSet (
src/SelectionSet.h/cpp): Tracks selected SceneNodes, Entities, and SubEntities. EmitsselectionChangedand 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. ImplementsQtMouseListenerinterface.
- 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.
- MaterialEditorQML (
src/MaterialEditorQML.h/cpp): QML_SINGLETON exposing full Ogre material property access (colors, lighting, depth, blending, fog, textures) with undo/redo. QML UI inqml/.
-
TexturePaintController (
src/TexturePaintController.h/cpp, QML_SINGLETON): the in-viewport texture painter. Ray-hit the mesh → interpolate UV → paint into a CPUTexturePaintBuffer(RGBA8) → debounced dirty-rect blit to a liveOgre::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 inqml/PropertiesPanel.qml(texPaintCol) + the detachedqml/TextureEditorWindow.qml. Undo via inlineTexturePaintStrokeCommand/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 slotalbedo/normal_map/roughness/metallic/ao/emissive, Height→normal_map, VertexColor→the existing per-vertex path). Architecture: per-channel sessions — the livem_layerStack/m_bufferis the active channel's session;setActiveChannel()stashes the current channel's layer stack intom_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 vialoadImageAcrossGroups, alias-aware for BaseColor'sdiffuse_map, and skipping the transientQMEPaint_*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 themetallicslot the Cook-Torrance SRS reads; Height/Normal Sobel-bakes the painted grayscale to a tangent-space detail normal viaNormalMapGenerator::generatethen whiteout-blends it onto the existingnormal_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 INTOnormal_map(slotName(Height)==""), so a mesh carries one combined normal map, not separate height+normal. Height is NOT a selectable channel (removed from thepaintChannels()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'sm_originalTextureNamefirst). Height was the highest picker index (enum 6, just before VertexColor 7), so dropping it leaves the remaining picker list indices identical to theirChannelenum values — the QMLactiveChannel === indexbinding 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) →applyPbrIfTaggedonly 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,bindBakedChannelTextureprunes the just-baked slot fromm_boundSlotsand then TEARS THE LIVE SESSION DOWN (stashChannelSession+m_buffer.clearDirty()+closeSession()) instead of callingflushDirtyToOgre()— 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 transientQMEPaint_*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,MaterialPresetLibrarypattern): 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 intexPaintCol. Tests:PaintChannel_test.cpp,PaintChannelPresets_test.cpp(pure-data), plusTexturePaintController_test.cppscene-fixture cases (channel switch auto-creates slot, scalar bake → ORM, height bake → normal_map, per-channel session isolation). Session scoping:m_channelSessionsis 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 transientQMEPaint_*GPU texture name (which changes every channel/session switch).undo()/redo()callensureUndoTarget(entity, channel)which reselects the entity +setActiveChannelbefore 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(bitmaskSymAxisX=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 —mirrorLocalPointsiterates nonzero subsets) inside the same begin/end stroke window, so mirror dabs are captured by the existing singleTexturePaintStrokeCommand— one undo step, no new command. Works from BOTH the viewport (screen raycast →m_hitCache) and the 2D panel (applyBrushSymmetryDabsfalls back tofindMeshPointForUV, which also seeds the hit cache). Geometric resolveruvForLocalPoint(inverse offindMeshPointForUV): reflect the primary LOCAL hit across the axis — local: about the mesh originm_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; insteadbuild()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 bevalid()), andmirrorDab(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 inm_symmetryMapskeyed 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 inresetStrokePaintState) so each mirror stroke fans apaintBrushAlongSegmentand doesn't gap on fast moves. Plane viz:refreshSymmetryPlaneOverlaydraws a faint translucent quad per enabled axis (X=red/Y=green/Z=blue, alpha 0.12, depth-write off) onm_symPlaneNode/Obj(mirrorsdrawHoverRingAt), torn down incloseSession. Stabilizer:stabilizerMode(StabAverage/StabTrail),stabilizerAmount(0..100, default 0 = exact passthrough / zero latency). Smooths the raw SCREEN cursor before hit-testing inupdateStroke;beginStrokeseeds the buffer with the press point (first dab exact);endStrokedoes 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/stabilizeAveragePointweighted newest-heaviest/stabilizeTrailPointlag-distance) isstaticfor unit testing. Sentry breadcrumbspaint.symmetry(enable/space/axes/topology/map-build) +paint.stabilizer(mode/amount, once per stroke). QML: Symmetry + Stabilizer groups intexPaintCol. 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 aTexturePaintController_test.cppone-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 sharedprojectToViewportUV(world→viewport UV + NDC z + behind) /sampleImageout ofMultiViewTextureBakerso 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 ofMultiViewTextureBaker::bake, whose multi-view weighted accumulator is the wrong shape):project(tris, View, source, out, opts, occ?)clearsouttransparent 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 fromMultiViewTextureBaker::fromEntity. Occlusion (the hard part):MeshDepthRendererrenders a depth map from the camera (grayscale = LINEAR world distance via fog, near=bright/far=dark;RenderResultgaineddepthNear/depthFar); per texel, project world pos through the depth map's OWNviewProj, reconstructdMap = near+(1-g)*(far-near), and reject when the texel's camera-axis distancedot(wp-eye, camDir)(NOT Euclidean — Euclidean self-occludes off-axis texels, i.e. depth acne) exceedsdMap + 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 colorViewand the occlusionviewProjare kept strictly separate (the depth camera auto-frames, so its matrices differ from the live camera's). Projection surfaces (TexturePaintController): WRITE-backed propsprojectionMode(0 off / 1 stencil-brush / 2 camera-locked),stencilImagePath,projBackfaceCull,projUseOcclusion,projDepthLimit, read-onlycameraLocked. Stencil brush:paintColorFootprintAtUVdelegates toprojectDabthrough 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_projTriscached atbeginStroke; occlusion map refreshed at stroke start (mode 1) / onsnapProjectionCamera(mode 2), never per dab.projectFromPhotoprojects a photo through the camera into a scratch buffer and commits viacommitProjectedLayer(newGeneratedlayer + onePaintLayerOpCommand).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 ORTHOGRAPHICView(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:refreshDecalOverlaydraws a world-spaceManualObjectquad + corner(rotate)/edge(scale) handle squares (depth-off so grabbable), torn down incloseSession;placeDecalAt/decalHitTest(ray∩rect-plane→rect-UV)/dragDecal/commitDecal(→ProjectionPainter::project+occlusion→new layer)/cancelDecal. Viewport routing:TransformOperator::mousePressEventconsumes decal clicks BEFORE the paint-stroke branch (place while Placing, grab a handle while Editing),mouseMoveEventdrags, 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. Sentrypaint.projection.*/paint.decal.*. QML: a collapsible Projection group (mode/stencil/Snap/Backface/Occlude/Project-from-photo) + a Decal row (Place/Commit/Cancel + hint) intexPaintCol. 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; plusTexturePaintController_test.cppfixture 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): aPresetcaptures 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 (theGradientRamp::bundledPresetsprecedent); 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/, pluspushRecent(re-picking PROMOTES rather than duplicating, so the 12-slot ring stays distinct) andextractFromImagefor "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).Swatchcarries 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 (asBrushAssetLibrary::resolvePathdoes for stamps). Controller apply layer:applyBrushPreset/saveBrushPreset/brushPresetNames/isBundledBrushPreset/deleteBrushPreset/exportBrushPreset/importBrushPreset+colorPaletteNames/colorPaletteSwatches/recentPaintColors/applyPaletteColor/savePaletteFromTexture+ apaletteChangedsignal. 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 asStampLibraryDialog, 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). Breadcrumbspaint.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, plusTexturePaintController_test.cppcases. Gotcha found by mutation testing:m_activeStampNameis 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 (_MetallicSmoothnessRGBA with R=metallic and A=smoothness=1−roughness, AO separate, normal converted to DirectX +Y down viaflipNormalGreen), Unreal (_ORMRGB = occlusion/roughness/metallic; absent occlusion fills WHITE — a 0 lane darkens the whole surface), Godot (separate textures + a.trescarrying each texture's sRGB-vs-linear flag, the classic washed-out/too-dark import bug), glTF (_metallicRoughnesswith G=roughness, B=metallic, R free for occlusion).targetFromIdrejects 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 throughTextureChannelPacker/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 (whatbakeChanneldoes 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 (theMaterialEditorQML::packTextureChannelsconvention) and writes every texture before reporting success — a partial set on disk looks complete;bakeTargetIds/bakeTargetLabel/paintedChannelIdsfeed the dialog;bakePreviewUrlcaps at 512px (base64 data-URIs cost ~80ms at 2048 vs ~3ms small — seePaintBufferImageProvider);bakeVertexLayerToTextureLayerrasterises vertex colours into a new texture LAYER viaVertexColorBakerwith a transparent background (the pre-existingbakeVertexColorsToTextureclobbersm_bufferand rebinds the model — it never produces a layer). Two traps:stackForChannelhonours the live-vs-stashed split (the ACTIVE channel's stack ism_layerStack; itsm_channelSessionscopy 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 throughcompositeTo(it hardcodessolo ? 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, lazyLoader; 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); CLIqtmesh paint-bake <file> --target <id> -o <dir> [--resolution N] [--prefix NAME] [--no-sidecar] [--json]/--list-targets; MCPpaint_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.loadSlottriesEmbeddedTextureCacheFIRST: 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-bakemust be inAppLaunchHandler's recognised-subcommand list or the binary silently takes the GUI path and hangs. Breadcrumbspaint.bake.start|done|error|vertex_layer. Sidecar schemaqtmesh-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,.tresfilenames matching the writer's stem),TexturePaintController_test.cppscene cases,CLIPipeline_cmdpaintbake_coverage_test.cppargument 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-dataBrushAssetLibrary/BrushPresetLibrary/ColorPaletteLibraryso they need no mesh and no render system),<file> --layer list [--json],<file> --bake --engine <t> [--resolution N] [--prefix P] -o <dir>(--engineis an accepted alias ofpaint-bake's--target, and both delegate to the sameCLIPipeline::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 addwould create a layer, write nothing, and exit — the omitted verbs print that reason rather than failing opaquely.--layer listreports 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:projectFromPhotoreads 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 shareprojectPhotoWithView, so occlusion/resolution/breadcrumb/commit behaviour cannot drift. MCP (14paint_*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'spaint_bake.MCPServerpreviously had ZEROTexturePaintControllercoupling, so this is a new bridge, not extra dispatch rows.paint_set_enabledis 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_channelrejects an unknown id instead of acceptingPaintChannelNS::fromId's BaseColor fallback (silently painting BaseColor when the caller asked for roughness is near-impossible to notice) and rejectsheightpointing atnormal(#547);paint_reorder_layerloops the controller's move-up/move-down rather than reaching into the stack, keeping the single-step invariants and breadcrumbs intact;paint_set_colorroutes throughapplyPaletteColorso 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.*andpaint.tabletare 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.cppandPaintLayerStack_test.cppwere the only two files #553 names that had no equivalent (ProjectionPainter_test.cppalready existed;PaintChannel_test.cppandSymmetryMirrorMap_test.cppare the*Router*/SymmetryEngineequivalents;DerivedMaps_test.cppwas dropped with Slice G).PaintLayerStackdoes NOT bounds-check —layer(index)throws, and that is safe only because everyTexturePaintController::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 LSPContent-Lengthframing — a newline-delimited JSON client hangs on the first read.
- LightManager (
src/LightManager.h/cpp): owns user scene lights as namedOgre::SceneNode+Ogre::Lightpairs taggeduser_light. Create/duplicate/rename/delete/apply-properties; emitslightCreated/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 viaqtmesh.scene.lightsglTF metadata (chunked when >1 KiB) plus.lights.jsonsidecar for FBX/glb.powerScaleToGltfIntensity()maps QtMeshEditor intensity → KHR_lights_punctual lux/candela (approximate; metadata is authoritative). - SceneLightsCLI (
src/SceneLightsCLI.h/cpp): headlessqtmesh light—--list,--list-rigs,--add,--remove,--edit,--apply-rigwith-oexport throughMeshImporterExporter::sceneExporter/ mesh export + sidecar. - MCP tools (
create_light,delete_light,list_lights,set_light_property,apply_light_rig): operate on the live editor scene viaLightManager/LightRigLibrary/SceneLightsIO::captureFromScene(). - Intensity units: GUI/CLI/MCP use Ogre
powerScale(Inspector “Intensity”). glTF export writes bothqtmesh.scene.lights(exact) and best-effort KHR punctual intensity derived frompowerScale × diffuse luminance. FBX lights are Assimp best-effort; use the.lights.jsonsidecar for bit-exact round-trip. - Sentry breadcrumbs:
scene.light.create|delete|duplicate|rename|edit|shadow_toggle|apply_rig|gizmo_toggleon core ops;ui.actionon 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 inqtmesh.scene.lightsJSON aslinkMode,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 taggedlight_groupscene node; transform/enable/disable as one unit. Persisted in scene JSON asrigGroups[]withgroupKind: "collection". - Viewport solo (#491):
ViewportLightSoloController— per-viewport runtime toggle to show only one light during that viewport's render pass (OgreWidget::frameEndedbegin/end hooks). Inspector picker under Scene → Lighting. - IES profiles (#491):
IesProfileLM-63 parser +IesLightApplymaps beam/field angles to spotlight cones (approximation; not full shader 1D texture yet). Inspector Browse/Clear; polar plot overlay inLightVisualizer. Persisted asiesProfilePathin 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 inLightVisualizer. Persisted asareaShape,areaWidth,areaHeight,areaSampleCount.
- 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 MCPtoggle_normalstool. Supports real-time animation: requests software-skinned normals viaaddSoftwareAnimationRequest(true)and updates each frame for skeletal entities. Overlays attach to dedicated child scene nodes to avoid unsafestatic_cast<Entity*>crashes inObjectItemModelandManager::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 MCPtoggle_mesh_infotool. Implemented as a top-levelQt::Toolwindow 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.
- 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::WindowStaysOnTopHintand software rendering (QQuickWindow::setSceneGraphBackend("software")) to avoid GL conflicts with Ogre.
- 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.
- EditModeController (
src/EditModeController.h/cpp): QML_SINGLETON managing Object/Edit mode state.Tabtoggles. In edit mode,1/2/3switch 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 singleEditMeshTopologyCommandfor 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
Fshortcut. Cross-submesh inputs and duplicates of existing triangles are rejected.
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-skinnedOgre::SceneNodes — animated props/doors/spinning machinery/camera moves/animated lights. Clips areOgre::Animation+AnimationStateowned by the SceneManager (not a mesh), driven byNodeAnimationTracks. Sub-slices shipped: C1 data layer (create/delete clip, per-clip{node→handle}allocator that replaced the collision-proneqHash & 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-CLIqtmesh 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/--keyframesfor more channels/nodes; seeds untouched channels from the node's current TRS; exports viasceneExporterso glTF is native + FBX/.mesh get the.nodeanim.jsonsidecar). (#520) C5 export (shipped for glTF/glb):buildNodeClipAnimations()inMeshImporterExporter.cppwalks the SceneManager's node clips and emits oneaiAnimationper clip withaiNodeAnimTRS channels targeting each scene node BY NAME (the same namebuildSceneAiScene()gives the entity's aiNode), so node-transform animation now survivessceneExporterto 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/reconstructNodeClipsFromFilerebuild clips from the aiNodeAnim channels on glTF/glb import; for FBX + .mesh — whose exporters (customFBXExporter/ OgreMeshSerializer) have no concept of SceneManager node animation — the clips are persisted to a<basename>.nodeanim.jsonsidecar (writeNodeAnimSidecar, schemaqtmesh.node.animations.v1, mirrors the.lights.jsonpattern) on export and rebuilt byreconstructNodeClipsFromSidecaron import. Skeletal+morph coexistence gotcha (fixed): Assimp imports a glTF morph-weight animation as a 0-channelaiAnimation;AnimationProcessor::processAnimationmust 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"), whichbuildAiScenere-emits empty whileinjectMorphWeightAnimationsappends the real one → two same-named glTF animations → Ogre aborts the whole re-import ("animation already exists", 0 entities loaded).injectMorphWeightAnimationsalso dedupes by name as a belt-and-suspenders. Regression-guarded byscripts/anim-combined-roundtrip.sh+tests/fixtures/combined_skel_morph.glb. C-GUI (this slice): node clips are ticked in the render loop (MainWindow::frameRenderingQueuednow advances SceneManager-levelAnimationStates — 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 tracksNodeAnimationManager.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). Sentryscene.anim.node/scene.anim.node.cmd.scrubClipis intentionally a no-op on the node — an enabled AnimationState makes_applySceneAnimationsre-drive the node every frame and lock it against gizmo edits; the authoring model is "paused = editable, Play = preview" (the node clip's ownAnimationState.enabledflag is the Play toggle, andframeRenderingQueuedadvances SceneManager states BEFORE theisPlayingearly-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 inAnimationRetargeter_test.cpp) + a thin Ogre adapter (describeSkeletonorders bones parents-first by depth,sampleAnimationsnapshots + restores the live pose incl. manual bones,retargetwrites a NEW clip relative to the target bind:kf.rot = bind⁻¹·local,kf.translate = local − bind). Algorithm: per mapped boneT_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'sA. 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 pickedFoot.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-v1JSON; bundledmixamo_to_humanik=mixamo_to_unityandmixamo_to_unreal;resolveMaprebinds names leniently (mixamorig:map on amixamorig1:rig). Undo:commands/RetargetAnimationCommandsnapshots the created clip keyframe-for-keyframe (redo rebuilds it without the source), entity resolved by name. Surfaces: wizardqml/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); CLIqtmesh anim <src> --retarget <tgt>(CLIPipeline::cmdAnimRetarget); MCPretarget_animation(scene entities with undo, or transient file import +output_path;dry_runreturns the map). Sentryscene.anim.retarget.{automap,map_edit,bonemap_load,bonemap_save,preview,apply,error,cli,cmd}; gamificationanimation_blend.QTMESH_RETARGET_DEBUG=1prints 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).AnimGeneratorsis pure data (target grammarkind:object[/sub]/channel[@clip]— bone names may contain ':' so only the FIRST ':' splits the kind —, evaluation, sampling,qtmesh-anim-generators-v1JSON,applyParamfor CLI/MCP keys), tested inAnimGenerators_test.cpp. Layer model: every type ADDS to the base (base(t) + Σ active); follow-path REPLACES position (and orientation withorient). Two bindings: TRACK-BACKED targets (bone TRS on a skeletal clip, node TRS on a node clip — default clipGenerators, 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 owngetInterpolatedKeyFrameon 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 becomesP − 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 newPoseLibrary::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 bytick()fromMainWindow::frameRenderingQueuedon 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 ONEAnimGeneratorDocCommandswapping two documents (generators + base snapshots);applyDocumentrestores 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.jsonsidecar beside every single-entity export (only generators aimed at that entity, its node or its materials;metarecords 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 fromqrc:/AnimationControl/) — add (type/kind/object/sub/channel/clip pickers fed byobjectsFor/subsFor/clipsFor/channelsFor), list with live/mute checkbox + Bake + remove, per-type parameter fields, path point list + "Edit in viewport" (overlay polyline + points;TransformOperatorroutes a press on a point tobeginDrag, camera-plane drag anchored at press, one undo step on release); CLIqtmesh anim <file> --generator … --target … [--<param> v] [--bake] -o out/--list-generators(CLIPipeline::cmdAnimGenerators; node/light/material targets export the scene); MCPlist_generators,add_generator,set_generator(params +enabled),bake_generator,remove_generator. Sentryscene.anim.generator.{add,remove,edit,enable,bake,path,capture,save,load,cli,undo,redo,error}; gamificationanimation_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.AnimConstraintsis pure maths +qtmesh-anim-constraints-v1JSON (refsnode:Name/bone:Entity/Bone, first ':' splits), tested inAnimConstraints_test.cpp. Evaluation runs from aSceneManager::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 bumpEntity::_updateAnimationnever 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 +_applySceneAnimationsper sample), writes bind-relative keys for every touched bone incl. IK ancestors into the skeletal clip, node owners into their node clip or a newConstraintsclip, then mutes the baked constraints; oneConstraintDocCommand(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;metanames rebind on renamed import). Not part of retargeting. Surfaces: Inspector Animation-mode "Constraints" (qml/ConstraintsPanel.qml), CLIqtmesh anim … --list-constraints | --constraint … --owner … --target … --bake-constraints(CLIPipeline::cmdAnimConstraints), MCPlist_constraints/add_constraint/set_constraint/move_constraint/remove_constraint/bake_constraints(AI capabilityanimation). Sentryscene.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-orderedQStringListso 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 (flipBoneNameheuristic:_l↔_r,.L↔.R,Left*↔Right*; TRS flip ispos.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.poselibsidecar JSON (schemaqtmesheditor.poselib.v1, atomicQSaveFilewrite, 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]andqtmesh 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, rotationQuaternion::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 eachtickBlend(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 <= 0snaps (same contract asapplyPose), and a snap-apply cancels any in-flight blend so the next tick doesn't fight the user's action.MainWindow::frameRenderingQueuedcallstickBlendbefore and independently of theisPlayinggate, with rawdtnotscaledDt— 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 viaqrc:/AnimationControl/PoseLibraryPanel.qml, gated onSkinWeightsController.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*Undoableentry point that pushes a command, so each is ONE Ctrl+Z step; the new commands areMirrorPoseCommand,BlendPosesCommand,ApplyPoseMaskedCommand(captures only the masked bones — capturing the whole skeleton would let undo revert edits the mask was supposed to protect) andApplyPoseBlendedCommand(undo cancels the in-flight blend FIRST, then restores, else the next tick drags the skeleton back off the restored snapshot). Thumbnails:poseThumbnailForSelectionposes the entity to the snapshot, renders oneModelTurntableRendererframe (studiolighting so untextured rigs read as shapes), restores the pre-existing live pose, and returns adata: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 onforgetEntity(entity pointers get reused). Headless/no-GL returns an empty string and the panel just shows a placeholder — never an error..poselibexport/import raiseexportLibraryRequested/importLibraryRequestedand MainWindow runs theQFileDialog(QML can't parent one) — theHdrEnvironmentController::browseRequestedsplit. Auto-persistence:MeshImporterExporterwrites a<basename>.poselibsidecar on every SINGLE-ENTITY export (writePoseLibrarySidecar, next to the.nodeanim.json/.lights.jsonwrites; removes a stale sidecar when the library is empty so a deleted library doesn't resurrect) and reads it back on import (loadPoseLibrarySidecar, besidereconstructNodeClipsFromSidecar), so "save → close → reopen" keeps the library with no manual export. Scene (multi-entity) export is covered too:sceneExporter/sceneImporterwrite and read the same<basename>.poselibpath (foo.scene.glb→foo.scene.poselibviacompleteBaseName), 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 throughwriteScenePoseLibrarySidecar/loadScenePoseLibrarySidecar). The NODE name is the key because that's whatsceneExporterwrites into the glTF andsceneImporterrecreates — anEntity*can't survive a reload and entity names aren't scene-unique.entitiesand the single-entityposesare 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. Sentryscene.anim.pose/scene.anim.pose.cmd; gamification clusterpose_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) viaAnimationControlController, and morph weight keyframing over time (set_morph_weight_keyframe,clear_morph_weight_keyframe) viaMorphAnimationManager(the pre-existingset_morph_weightis 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;+ Addcaptures the current edit as a target (delta vs base);endMorphSculpt()/ exiting Edit Mode restores the base (the target lives on as aPose+ weight).EditableMesh::commitToEntityrefreshes the pose buffer (AnimationStateSet::_notifyDirty()+entity->_updateAnimation()) for vertex-animated entities so edits render live while the frame loop is paused. Reorder viaMorphAnimationManager::moveMorphTarget/moveMorphTargetToIndex(undoableReorderMorphTargetsCommand— 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 clipMorphAnimationManager::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:buildAiSceneemits blend-shape targets (aiMesh::mAnimMeshesviaattachMorphTargetsToAiMesh) AND a morph-weights animation (aiMeshMorphAnimfrom 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'sVAT_POSEpath so the existing timeline/dope-sheet/loop play it with no new playback code.FrameSet/FrameDataare the source-agnostic decoded-cache types;buildClipFromFrames(mesh, name, frames)reads submesh-0 bind positions and builds oneOgre::Poseper frame (delta vs bind) + oneVAT_POSEtrack keyed per frame time.sampleHeuristic(frameCount)(< 32 → poses, else stream — the issue's rule) is static + unit-tested. Sentryscene.anim.vertex_anim. -
AlembicImporter (
src/AlembicImporter.{h,cpp}, Slice B2): Alembic (.abc) reader behind-DENABLE_ALEMBIC(default OFF;cmake/Alembic.cmakeFetchContents Imath 3.1.12 + Alembic 1.8.8, both BSD-3, fully static, all optional components off — Imath install stays ON so Alembic's unconditionalinstall(EXPORT)finds it in an export set).readFrameSetdecodes the firstIPolyMeshinto aFrameSet(pure data — rejects variable-topology caches, fan-triangulates n-gon faces);importToScenebuilds the base mesh +VAT_POSEclip + entity..abcroutes throughMeshImporterExporter::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); MCPimport_alembic(heavy — decodes into the live scene, reports node/entities/vertexClips) +play_vertex_animation(delegates totoolPlayAnimationsince a vertex clip is an ordinaryAnimationState). Frame cap:readFrameSet(maxFrames)andimportToScenecap the decode at 512 frames and setReadResult::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.
- UndoManager (
src/UndoManager.h/cpp): Singleton wrappingQUndoStack. Push commands, undo/redo viaCtrl+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.
- 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).
- 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.
- 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 undermedia/hdri/; user downloads land in<AppData>/hdri/. IBL disk cache:<AppData>/hdr_cache/<sha1>/(delete that folder to force a rebake; seeHdrCache::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 viaHdrViewportController. - HdrMaterialScript (
src/HDR/HdrMaterialScript.h/cpp, Slice D): serialises per-materialpbr_environment_intensity/pbr_environment_tintlines in.materialsidecars; 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). SeeTHIRD_PARTY_HDRI.md. - Slice G (#473) parity:
qtmesh material --env/--env-intensity/--env-tint; MCPset_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.actionon inspector changes. - Stretch (not in 3.15):
qtmesh renderheadless tonemapped preview PNG — turntable/isometric paths cover most batch preview needs today.
- 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-loadstudio_neutral.hdrwhen no IBL is active; Sentry breadcrumbrender.hdr.preseton apply. - HdrBundledLibrary (
src/HDR/HdrBundledLibrary.h/cpp, Slice F #472): CC0 bundled HDRIs undermedia/hdri/(~25 MB total at 2k), first-run defaults (studio_neutral+ ACES + 0 EV), andqtmesh hdri --downloadfor optional Poly Haven fetches into<AppData>/hdri/. SeeTHIRD_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 theqtmesh pack-texturesCLI subcommand, thepack_texturesMCP 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-styledWindowwith a 256×256 live preview thumbnail (slice G2 —MaterialEditorQML::previewPackedTextureChannelsreturns adata:image/png;base64,…URL the QML Image element shows directly),DropAreaon 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.strengthscales the gradient (clamped to [0..32]);invertRandinvertGflip the corresponding channels —invertGis the OpenGL (+Y up, default) ↔ DirectX (+Y down) switch. Output is RGB8. Surfaced viaqtmesh normal-from-heightCLI subcommand, thegenerate_normal_mapMCP 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 viaqtmesh atlas --inputs a.png,b.png,... -o atlas.png [--size 2048] [--padding 2] [--manifest atlas.json], thepack_atlasMCP 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::previewAtlasreturns adata: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 bymanifestToJson) and applies it to anOgre::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 anOgre::Material(very common — Mixamo exports re-use oneSkin_MATacross 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); passclampOutOfRangeUVs=false(CLI--no-clamp) to leave them untouched and surface them asoutOfRangeUVsin the report. After each unique material is mutated we callRTShaderHelper::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 astrippedExtraTexturescount so the caller can confirm what got removed. Surfaced viaqtmesh atlas-apply mesh.fbx -o atlased.fbx --manifest atlas.json --atlas atlas.png [--match {basename|fullpath}] [--no-clamp] [--keep-extras] [--json], theapply_atlasMCP 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 Nadds decimation. Emits a per-stage applied/summary report (text or--json). Same surface on MCP viaoptimize_mesh(file in / file out). Rumba Dancing.fbx → optimize with--reduction 0.5shrinks 6.3 MB → 1.4 MB (77.7%), ACMR 0.822 → 0.648, 42% of redundant keyframes stripped.
- Connection (
CloudCredentialStore,QtMeshCloudClient): device-flow login (qtmesh cloud login) or API-key login (--api-key, MCPcloud_login). Session bearer tokens persist in per-userQSettings(non-prompting; seeCloudCredentialStorefor rationale).QtMeshCloudSessionruns 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/--excludeglobs viaCloudUploadPlanner::selectedPathsForUpload. - Upload protocol (
QtMeshCloudClient,QtMeshCloudSession):POST /v1/projects→POST …/files/upload-urls→ signedPUTper file →POST …/files/complete→ optional scan reportPUT …/files/:id/report(5 MB client cap). Progress surfaces throughQtMeshCloudSession::uploadProgress; CLI streams events to stderr (--jsonemits 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-confirmfor CI. API base override:QTMESH_API_BASE(tests + self-hosted). - Limits:
qtmesh cloud limits/ MCPcloud_limitsread server caps fromGET /v1/auth/mewhen exposed; scan reports are capped at 5 MB client-side.
- GamificationManager (
src/GamificationManager.h/cpp): singleton orchestrator (also a QML singleton under bothWelcomeScreen 1.0andPropertiesPanel 1.0). Instrumentation is two static one-liners:GamificationManager::noteFeature("<feature_key>", Surface::Gui|Cli|Mcp)at controller entry points (E-P2 #798) andnoteOperation("<op>", {{"tris_before", n}, …})where before/after metrics exist (E-P3 #799).noteOperationalso 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 callsflushBlocking()before_exit. Batches go toPOST /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 inGamification::featureCatalog()(src/GamificationTypes.h/cpp) and MUST match qtmesh-cloud'sDISCOVERY_FEATURES.GET /v1/me/statsparses intoGamification::StatsSnapshot(NB mixed casing on the wire:stats/featureUsagesnake_case,progress/achievementscamelCase); cached to<AppData>/gamification/stats_cache.jsonfor offline rendering. Milestone progress ("nearest unlockable") is computed client-side frommilestoneCatalog()+ counters. - Status surface (E-P4 #800): level + XP bar, streak and nearest-unlockable progress render in the
CloudAccountMenuButtonmenu (refreshes stats opportunistically on menu open); "View My Achievements…" openshttps://qtmesh.dev/u/<slug>(profile is private by default —setProfilePublicPUTs/v1/me/gamification/prefs). Unlocks show ONE coalescedGamificationToast(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, seeAppSettingsKeys.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 inCLIPipeline::run.
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 areFirstExport,ImportFailure,ExportFailure, andSessionNoExport(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 afterkMaxDismissals(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:
maybePromptchecksisSignalConnected(promptRequested)before consuming the one-per-session budget, so a CLI/MCP run never burns it (theGamificationManager::maybeRequestConsentpattern). - UI: non-modal
QMessageBoxinMainWindow(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 existingFeedbackDialog, prefilled from the trigger. - Transport:
QtMeshCloudClient::submitFeedbacknow accepts either a bearer token oranonymousInstallationId(qtmesh-cloud#107 addedPOST /v1/feedbackanonymous support, migration 0028, rate-limited per install).authorizedJsonRequestomits theAuthorizationheader entirely when there is no token — a bareBearerreads 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
FeedbackDialogwith the user's comment, and "Got what I needed" fires a silent message-lessrating: greatsubmission (postSilentRating, detached worker sincesubmitFeedbackblocks) — without that the table would collect complaints exclusively and could not answer "did they finish?", which is half the churn question. Sentry getsfeedback.prompt_shown|dismissed|positive|negative|submittedviacaptureTelemetryEventwith structured metadata plusmessage_length— never the message text, sincesanitizedValuestrips 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 byfeedback_id. - Settings keys live under
Feedback/inAppSettingsKeys.h.
- 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_BINDopt into0.0.0.0(containers with a mapped port), and a non-loopback bind without a token logs a warning; (2) tools execute only viaPOST /api/tools/<name>—GET /api/tools/<name>answers405+Allow: POSTand dispatches nothing (it used to run the tool with no arguments, so any link/prefetch could decimate a mesh);GET /api/toolsstill 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), elseQTMESH_HTTP_TOKEN, else QSettingsmcp/httpToken(MCPServer::resolveHttpToken).--http-token <secret>on argv is refused at startup (exit 2) — review caught that a command-line secret sits inps//proc/<pid>/cmdlinefor 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 sendAuthorization: Bearer <t>orX-Api-Key: <t>, else401+WWW-Authenticate; the compare is constant-time (httpRequestAuthorized, pure + unit-tested). Default (no token) stays open to local processes, so thescripts/anim-*.shharnesses (all POST, plus aGET /api/toolsliveness probe) run unchanged.AppLaunchHandler::isGuiModeValueFlagskips the--http-port/--http-token(-file)/--http-bindVALUES in BOTHcollectGuiLaunchPaths(a token file never becomes a launch path) ANDisCliInvocation(a value equal to a subcommand name or--cli—--http-token-file scan— must not reroute the whole launch intoCLIPipeline::run; review finding) — and that skip is now checked BEFORE the generic leading--continueincollectGuiLaunchPaths; it used to sit after it, i.e. was dead code, and--http-port 8080only appeared to work because "8080" is not an importable file (the test uses a real.objas the value so the skip is observable). take_screenshotcaptures via an Ogre RTT, NOTQWidget::grab()— Ogre renders straight to the native window surface (WA_PaintOnScreen) sograb()returns a black buffer. The tool renders the active viewport'sSpaceCameracamera into an offscreenPF_BYTE_RGBArender target (RTSSMSN_SHADERGENscheme + a temporary ambient boost & directional key light so imported materials aren't black, restored after) and reads it back to PNG.load_meshcallsframeSceneInActiveViewport()(select every user node →frameSelection()) so a headlessload_mesh→take_screenshotactually frames + shows the mesh. This makes autonomous visual QA (load → optionally explode viatransform_submesh→ screenshot) work without a GUI operator.
- CLIPipeline (
src/CLIPipeline.h/cpp): Headless command-line interface for mesh operations. All static methods — entry point isCLIPipeline::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
qtmeshsymlink (created at build time),--cliflag, 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): PublicrenameAnimation()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 throughMeshImporterExporter(the editor's own loader) and walks the resulting Ogre scene withCLIPipeline::extractMeshInfo— the same extractorMeshInfoOverlayuses, so the scan, the CLIinfosubcommand and the in-app overlay all report identical counts for the same asset. Redundant-keyframe analysis (and the--fixwrite-back since slice C4) goes throughAnimationMerger::analyzeRedundantKeyframes/simplifyAnimation, the same code path asqtmesh anim --simplifyand the Inspector "Simplify" button. The fix path re-exports viaMeshImporterExporter::exporterfor every supported format (FBX/glTF/glb/DAE/OBJ/PLY/STL/.mesh) — noAssimp::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-processReadFileto enumerateaiMaterial::GetTexturereferences that Ogre's TUS-name walk wouldn't see when a referenced texture file is missing on disk (needed forrequire_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 inclearOgreSceneForScanImportwhich destroys scene nodes and flushes MeshManager / SkeletonManager so a 1000-asset scan doesn't accumulate state. - ScanConfig (
src/ScanConfig.h/cpp): Config loader forqtmesh.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.
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-winsFrameMailboxso live inference never falls behind),ImageSequenceFrameSource(headless test double / CLI--frames-dir).FrameDecimator/FrameMailboxare 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):
recordFacewrites weight keys viaMorphAnimationManager::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>_Headon the MESH skeleton) or node TRS.recordBodyfeeds[frame][22]world quats intoAnimationMerger::applyMotionClip(worldFrame=true)— the #411 retarget, no fork.RecordMocapClipCommand/RecordBodyClipCommandmake 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 (morphsetWeight+ 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 withalgorithmUsed/fallbackReasonuntil the export is hosted (decision record inTHIRD_PARTY_AI_MODELS.md). - Surfaces: CLI
qtmesh mocap(src/Mocap/MocapCLI.cpp, the SceneLightsCLI pattern); MCPcapture_face_from_video/capture_body_from_video(heavy) +list_capture_devices/start_live_capture/stop_live_capture(GUI-attached). Sentryai.assist.mocap_face|mocap_body|mocap_live; gamification clustermocap(cloud-sideDISCOVERY_FEATUREScoordination required). Hosting:scripts/upload-mocap-models.sh-> HF models repomocap/{face,pose}/(+ Apache NOTICE). User guide:docs/MOCAP.md.
- Static parsers (
src/PS1/):PS1TMD,PS1TIM,PS1RSD,PS1PLY,PS1MATfor known PlayStation mesh/texture formats. - Runtime extraction (
src/PS1/runtime/, epic #412):ENABLE_PS1_RIP(OFF by default). When ON,PS1RipManagerruns anEmuCorehost from<app>/PS1Cores/on a worker thread: preferqtmesh_ps1core_libretro(loadsbeetle_psx_qtmesh_libretro(rip fork, tried first) /mednafen_psx_libretro/ beetle fromPS1Cores/, system libretro paths, orQTMESH_PS1_LIBRETRO_CORE) for real ISO playback; fall back toqtmesh_ps1core_stubfor 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 categoryps1.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, branchqtmesh-rip, artifactbeetle_psx_qtmesh_libretro.*, all changes behindHAVE_QTMESH_RIP) exposes a versioned C ABI (rip/qtmesh_rip_abi.h, vendored byte-identical atsrc/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:LibretroHostoptionally resolves the 3qtmesh_rip_*symbols;LibretroEmuCoreregisters trampolines (ABI-version-checked, refused on mismatch), mirrors the armed flag into the core each frame, and unregisters before unload;RipperHooksbuffers 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); attributiongp0_incoreoutranksgp0_hook; in-core cap 16384 prims/frame with overflow breadcrumb.MeshReconstructorconsumes the tiers (#816): tracked verts land exact model-space coords, prims group on real record matrices, instances carryrot/trWorld/hasMatrix. Build the fork viascripts/build-ps1-rip-core.sh(pinned commit) or-DENABLE_PS1_RIP_CORE_BUILD=ON;QTMESH_PS1_RIP_INCORE=0A/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.cppdrive the full chain through the real plugin trampolines. Golden metric bars incl. the custom-engineretail-cscene:src/PS1/golden_captures.md. - Capture cleanup (#428):
Ps1NormalizerSettingsopt-in toggles applied during reconstruction —cleanupWeldNormals(weld coincident verts + smooth normals),cleanupRemoveZeroArea(drop collinear/duplicate-vertex sliver triangles viaMeshReconstructor::applyZeroAreaTriangleCull), plus the existingspikeEdgeFactordegenerate-span cull. Surfaced as the ripper toolbar "Smooth" / "Drop slivers" checkboxes, MCPps1rip_capturesmooth/remove_zero_area, CLI--smooth/--drop-slivers;ps1.rip.cleanup.*breadcrumbs. ScanEngine flags dirty captures viaps1-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/ MCPmerge_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), MCPps1rip_capturemerge_objects(default true), CLI--no-merge-objectsopt-out (--merge-objectskept as a back-compat no-op); breadcrumbps1.rip.cleanup.merge_objects(mergedGroups + nonRigidGroups + duplicateTrisDropped, also inMeshReconstructionStats). Unit tests:MeshReconstructorMerge_test.cpp. - Rigid animation capture (#429):
Ps1AnimationExtractor(Ogre-free, unit-tested) groups a scene capture's per-framegteRecordsinto one matrix track per moving object (identity = seq-contiguous same-matrix run → object-space vertex-set hash; keys ordered by frame).PS1RipMeshBuilder::authorRigidAnimationmatches each track to its capture node by first-frame world translation and authors anOgre::NodeAnimationTrackviaNodeAnimationManagerso 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 viaPs1NormalizerSettings::captureRigidAnimation(ripper "Rigid anim" toggle / MCPrigid_animation/ CLI--rigid-animation); scene capture only; best on a static-camera scene (GTE matrices are View×Model).ps1.rip.animbreadcrumb. - Headless CLI (#431):
qtmesh ps1 capture|dump-vram(CLIPipeline::cmdPs1) drivesPS1RipManageron its worker thread, pumping the Qt event loop (noexec()), then exports viaMeshImporterExporter::sceneExporter. JSON input scripts ([{frame,button}, ...]) +--auto-inputfor reproducible/unattended captures;initOgreHeadless()so the reconstructor has a scene; Xvfb on Linux. MCP parity was already theps1rip_*tool set.
- MeshImporterExporter (
src/MeshImporterExporter.h/cpp): Static methods. Supports .mesh, .obj, .dae, .gltf, .fbx via custom Assimp processors insrc/Assimp/. Also providessceneExporter()/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 by1/maxExtentso the largest dim lands at ~1 unit — without this they sit inside the camera near-clip plane and never render.configureCamera()readsgetWorldBoundingBox(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=ONonly wires the Draco decoder into the glTF2 importer (verified against the Assimp 6.0.5 source:glTF2Exporter.cpphas no draco references;glTF2Asset.inlguards the decoder underASSIMP_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) withQJsonDocument, and for each indexed-triangle primitive with POSITION it reads the geometry accessors, builds adraco::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 carriesJOINTS_n/WEIGHTS_n(skinning must stay bit-exact), a non-FLOAT attribute (e.g. normalized-integerCOLOR_0), or morph targets (primitive.targets), the whole primitive is left uncompressed. Compressible attrs (POSITION/NORMAL/TEXCOORD_n/COLOR_n float/TANGENT) are encoded viadraco::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; theKHR_draco_mesh_compressionextension is added to the primitive ({bufferView, attributes:{SEMANTIC: dracoUniqueId}}); the compressed accessors getbufferView/byteOffsetstripped AND theircountrewritten to the Draco decoded point count (Draco may merge duplicate points — a stale count fails validators); the extension is added toextensionsUsed+extensionsRequired. Orphaned geometry bufferViews are then garbage-collected (via a generic recursive scan of the whole JSON forbufferViewrefs, 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--compressup front). Build:-DENABLE_DRACO=ON+ Draco present — CI builds the vendoredcontrib/dracoas a standalone static lib and installs it beside Assimp (auto-discovered fromassimp_DIRprefix bycmake/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) carryJOINTS_n/WEIGHTS_non 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 bothMaya|TEX_color_map(which Assimp routes toaiTextureType_BASE_COLOR) ANDDiffuseColor(legacyaiTextureType_DIFFUSE) so reimport recreates thediffuse_mapslot 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'sFBXConverter::SetTexturePropertiesrecognises and translates to the matching aiTextureType. Normal map keeps the standardNormalMapproperty. MaterialProperties70writes shininess asShininessExponent(Assimp readsAI_MATKEY_SHININESSonly from that name; the legacyShininessis silently dropped on reimport). Texture payloads are embedded viaVideo.Contentfrom one of three sources, tried in order: (1) the import-timeEmbeddedTextureCache(textures Assimp extracted from inline FBXVideo.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 fromaiScene::GetEmbeddedTexture.MaterialProcessor::loadTexturestashes (thestd::byte*overload keeps the reinterpret_cast at one site);fbxResourceBytes::readinsideFBXExporter.cppretrieves first, before any resource-group or filesystem lookup. Pure-data — no Ogre dependency. The cache lives for the process lifetime; callclear()after a finished import session (i.e. once everyMaterialProcessor::loadTexturefor that asset and everyVideo.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 callclear()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 legacyaiTextureType_DIFFUSE/_NORMALS/_HEIGHT/_NORMAL_CAMERAplus PBR types (_BASE_COLOR,_METALNESS,_DIFFUSE_ROUGHNESSwith_SHININESSfallback,_AMBIENT_OCCLUSION,_EMISSIVEand_EMISSION_COLORfor 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 viaaiTextureType_BASE_COLORor via the legacy DIFFUSE alias fallback). When noBASE_COLORis exposed but a legacyaiTextureType_DIFFUSEwas, the importer aliases the diffuse texture underalbedo(non-FFP). When albedo IS exposed butpass->diffuseis 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. CallsRTShaderHelper::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 taggedpbr_workflowon import — that would auto-promote toSRS_COOK_TORRANCE_LIGHTINGvia theapplyNormalMapredirect, 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.
- 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:
startDownloadrefuses any URL that is nothttps://<host>or a host-lessfile://(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 orlocalhosthost is accepted, and the error names the reason) — refused BEFORE any filesystem side effect, so no.partor directory is created. An optional 4th argumentexpectedSha256(case-insensitive hex; empty = legacy no-check, so the 21 consumers + the two QML call sites inAISettingsDialog.qmlwork unchanged) is verified by a local streamedQCryptographicHashhelper on the finished.parton disk, before the rename (deliberately NOT the updater'sUpdateVerifier::sha256HexOfFile:qtmesh_updateris only built/linked underENABLE_AUTO_UPDATER, whileModelDownloadercompiles unconditionally — reusing it broke the-DENABLE_AUTO_UPDATER=OFFlink, caught in review; six lines of Qt API beat coupling every model download to the optional updater + libsodium) — never as a running hash inonReadyRead, because a resumed download appends to bytes this process never saw. On mismatch the.partis deleted (a poisoned partial must not be resumed from or cached) anddownloadErrorfires. Two gotchas verified empirically: (1) the scheme refusal is emitted QUEUED (QMetaObject::invokeMethod+Qt::QueuedConnection), never synchronously — consumers connect, callstartDownload, thenloop.exec(), so a synchronousloop.quit()fires beforeexec()and is lost, hanging them for their full timeout (the #1017 review race); queued delivery lands insideexec()for every consumer without touching any — proven onPhotoDepth, which has nosettledguard, failing in 1s instead of 600s. (2) Both refusals log atqCritical, notqWarning:cliMessageHandlerdrops warnings unless--verbose, and every consumer discardsdownloadError's text (#1037), so a warning would leave the user with "offline?" and no way to learn the cause. Resume verification (#1036): a resume sendsRange: 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.partproduced 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 FIRSTreadyReadof a resumed request (m_resumeUnverified, armed at both Range-request sites) the downloader requires 206 + aContent-Rangestarting exactly at the resume offset; anything else is treated as the full body — the.partis reopenedTruncate,m_resumeOffset/m_bytesReceivedreset 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/13is 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 withdownloadErrorand removes the.part(next attempt starts clean); only a 206 covering exactly0..total-1— a full body wearing a partial status — is truncated-and-taken like a 200. Checked once, not per chunk.FakeNetworkReplyin the tests defaults to a plain 200 (what an ignoring server returns) andwithPartialContent(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— becausebytes 9-10/13starts 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, resetm_bytesReceived/m_resumeOffset(a stale offset would makeresumeDownloadre-request the old range against a fresh file), abort the reply underm_abortingInternallyso the synchronously-deliverederrorOccurred/finishedstep aside, end the download, emit exactly ONEdownloadError(the test'sFakeNetworkReply::signalOnAbortmodels the synchronous delivery); (3)onDownloadFinishedrefuses to promote whenm_resumeUnverifiedis still set (a resume reply that finished without ever delivering data — verification never ran) or when the.partsize differs from the size the response committed to (m_expectedTotalBytes: the 206 total, else a full body's Content-Length) — the.partis 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 aqWarning(chunked transfer; HF/GitHub and QNAM'sfile://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 aboveAIChatManager/LLMManager—User → AIAgentManager → Planner → Capability router → Executor → Observer → Replan/Finish. All task state lives inAIAgent::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 inAIAgentManager_test.cppby a scriptedAgentPlannerBackend+AgentToolExecutor(no LLM, no Ogre). Capabilities, not a catalog:AICapabilityRegistrygroups 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 inother, never dropped). The planner sees a ~20-line capability index plus the FULL per-tool docs only for the capabilitiesrouteByKeywords()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 livebuildToolsList()schema (name, one-line description, params with type/required/enum), so they cannot drift. Constrained protocol (#1003):validateArgumentschecks 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 aninvalid_argumentsobservation and a replan, never a tool call. Observations (observationFromToolResult) parse facts out of tool text (Vertices: N, JSONboneCount,fallbackReason→warning), collect file artifacts, keeprawfor 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 byLimits(12 steps, 2 replans, 2 planner retries); the sameStep::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,endMacroon 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); unlesstrustedMode(QSettingsai/agentTrustedMode) the task pauses inAwaitingConfirmationand 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 deterministicsummarize()(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(QSettingsai/agentMode, default ON) routessendMessagetoAIAgentManager::startTask; while the agent drives, the v1 slots ignoreLLMManagercallbacks (m_agentDriving) andLlmPlannerBackendforwards generation signals only while it has a pending request. QML:qml/AIChatPanel.qmlgained 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 — itsModelInfodescription says so). Sentryai.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 aselect_entity {name}MCP tool (node or entity name; empty clears),get_scene_infoends with aSelected: …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::clearHistoryclears 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 wrotematerial_nameand the validator rejectedapply_materialtwice for a missingmaterialalthough the MCP handler itself acceptsmaterial_name— the validator must never be stricter than the tool, sonormaliseAliasesmaps 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 intosetFocus. (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 reachedllama_decode. The worker now checks againstllama_n_ctx(with a clear message naming both numbers) and emitscontextReady(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 defaultcontextSizeis 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.objasimage_pathtogenerate_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", andrejectMeshAsImagein 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.parttoo) 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 calledgenerate_mesh_from_imagewith~/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, inexecuteNextafter coercion): a non-existentimage_pathis dropped and, when nopromptwas 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 useprompt; 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 forimage_pathsays 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, sogenerate_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::buildSceneNodeis Ogre and main-thread-only — so insteadMCPServergained atoolProgress(tool, stage, done, total)signal that the generation tool drives fromMeshGenPredictor::predict's existingProgressFn(it already fires many times per stage and doubles as the cancel hook). The same callback pumpsprocessEvents(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 viagenerateSourceImageFromPrompt) already spun nestedQEventLoops, so it never froze; itsSDManager::generationProgressChangedticks are relayed to the same signal so the bar moves there too.McpToolExecutorconnects the signal toAIAgentManager::reportToolProgress→ thestepProgressLabel/stepProgressproperties (ignored when the agent is not busy; cleared around everycallTool), andqml/AIChatPanel.qml's plan card draws a labelled bar (indeterminate whentotal <= 0). (12) Stop must reach a running heavy tool. The first cut of (11) pumped withExcludeUserInputEvents, 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 deliversAllEvents(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 asfalse(→predict()returnscancelled) and which is also checked between the image and 3D phases,AgentToolExecutor::cancelRunningTool()(overridden byMcpToolExecutor) is called fromAIAgentManager::cancel()while Executing, andqml/AIChatPanel.qmlhas a Stop button next to the thinking dots. The plan card also stays visible after the run (itsplanCardPinnedguard 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_testfirst asserted aFocusIncount, 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()iswindow()->focusWidget() == this && window()->isActiveWindow(), andQFocusEvents are not delivered at all.window.focusWidget()is the one thing that tracks focus regardless — assert that, and gate anything else behindisActiveWindow().QT_QPA_PLATFORM=offscreendoes NOT reproduce this (it reportsisActiveWindow() == true, which is why both bad versions passed locally); a window that is nevershow()n does, on any platform —ClickFocusFilter.FocusWidgetIsSetEvenWhenTheWindowIsNotActivepins it that way. Rule for new widget tests: assertwindow.focusWidget(), neverhasFocus()or focus-event counts, unless gated on activation. (13-old) A focus-event assertion is not portable.ClickFocusFilter_testasserted a freshFocusInafter the filter re-asserts focus on an already-focused widget. Qt dispatchesQFocusEventonly 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 theclearFocus()+setFocus()PAIR via the focus-OUT count (onlyclearFocus()can produce it) and guards the event-count assertion behindwindow.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 anAIToolRouter: 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 samecanonical()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, andconfident(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.cppRoutingBenchmarkis the regression bar — everyneed_capabilitiesround 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 areTEST_F(AgentFixture, …)— a--gtest_filter='AIAgent*'does NOT run them; useAgentFixture*. 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 aroundModelDownloader; 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 (startDownloademitsdownloadErrorsynchronously when busy → the handler'sloop.quit()fires beforeexec()and is lost → the caller hangs for its full timeout).ensureBlockingowns 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: withRequest::expectedSha256set, 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, reportingOutcome::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_DOWNLOADguard, the timeout). Convention for exposing the reason:ensureModelBlocking(QString* error = nullptr)— done forPhotoDepthandTextureInpaint(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 theerrorout-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 owndonerace guard — nowModelFetch'ssettled; their(timeout)breadcrumb annotation comes fromOutcome::timedOut.MotionGenerator's oldguardtimer never cancelled the transfer on timeout;ModelFetchdoes. 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-rolledModelDownloaderwait left — otherQEventLoops 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 Pythonopen(p).read()sweep oversrc/raisesUnicodeDecodeError; useencoding='utf-8', errors='surrogateescape'for read AND write so bytes round-trip exactly.AIModelCatalog/LLMSettingsWidgetare GUI-async (no event loop) and already show the error text. Tests:ModelFetch_test.cppdrives the REAL singleton through QNAM'sfile://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.
- 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:
MaterialEditorQMLconnects 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,.ggufformats. #ifdef ENABLE_STABLE_DIFFUSIONguards 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.
- MeshOptimizerLod (
src/MeshOptimizerLod.h/cpp, issue #398): Thin facade overzeux/meshoptimizerfor LOD generation. Free functions, no singleton.generateLods(mesh, reductions)returns oneLodLevelper requested reduction, each with oneOgre::IndexData*per submesh. Usesmeshopt_simplifyWithAttributeswhen UV0 is present (preserves UV seams), falls back tomeshopt_simplifyotherwise. Every result ismeshopt_optimizeVertexCache-reordered (Forsyth) so the LOD is cache-friendly out of the box. Caller takes ownership of theIndexData*(commit toSubMesh::mLodFaceListor calldestroyLevel). - MeshLodController (
src/MeshLodController.h/cpp): Now hasAlgorithmenum (Ogre|Meshopt) on the C++ overloadgenerateLods(int, const QVariantList&, Algorithm). QML-facinggenerateLodsWithAlgo(int, QVariantList, QString)accepts"ogre"/"meshopt"for the Inspector backend dropdown. Default isOgre— meshoptimizer's attribute-weighted simplify preserves UV seams + skin weights but in practice produces a softer silhouette than Ogre's stockMeshLodGeneratoron character meshes, so Ogre stays primary. CLI:--algo ogre|meshopt(defaultogre). MCPgenerate_lodstool:algoparam (defaultogre). Sentry breadcrumb categoryai.assist.lodrecords the chosen backend when meshopt is used. - MeshDecimator (
src/MeshDecimator.h/cpp): SameAlgorithmenum exposed ondecimateEntity(entity, reduction, algo).MeshDecimatorController::applyReductionWithAlgo(double, QString)is the QML-facing variant the Inspector's Decimate section dropdown calls. CLIqtmesh decimate ... --algo ogre|meshopt; MCPdecimate_meshalgoparam. Same default and breadcrumb category as the LOD path (ai.assist.decimatefor meshopt). The post-decimationpromoteFirstLodToBasealso erases theqtme.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'ssd_img_gen_params_t.control_imagewith 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 MCPgenerate_mesh_texturetool, and the CLIqtmesh 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 onSDManager::generationCompleted); the CLI is a one-shot_exit()process so it loads the base model and drives the asyncSDManagerworker synchronously via two localQEventLoops (one formodelLoadCompleted/modelLoadError, one forgenerationCompleted/generationError/generationStopped), then copies the PNG next to the output mesh, binds it as the diffuse TUS on every submesh, and re-exports.SDWorker::generateTextureControlledcarries the control image;recreateContextloadscontrol_net_path+ disablesvae_decode_onlywhen 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 namedalbedo/diffuse_mapslots) — 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 categoryai.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.cmakedownloads the prebuilt ONNX Runtime 1.20.1 per-platform behindENABLE_ONNX(OFF by default; ON for release + the Linux coverage build) and exposes the importedqtmesh_onnxtarget — macOS uses the universal2 archive (no per-arch trap; CoreML EP inside, CPU EP fallback).PbrMapSynthis the Ogre-free, unit-tested core (NCHW packing, overlapping-tile inference with feathered seam blend, normal/height decode with strength + OpenGL/DirectXinvertG, roughness heuristic); it discovers the model's input channel count + output names/shapes at runtime rather than hardcoding, and falls back to the SobelNormalMapGeneratorto 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 itselfcc-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.pyis the one-time, offline, NOT-shipped dev tool that converts them to ONNX (spandrel load →torch.onnx.exportopset 18,dynamo=False, dynamic H/W).AIAssistManager(QML_SINGLETON, SDManager pattern) resolves each map's model underAppData/ai_models/pbr/<file>.onnx, downloads any missing ones on first use viaModelDownloader(base URL fromQSettings ai/pbrModelBaseUrlorQTMESH_PBR_MODEL_BASE_URLenv, defaulting to the hostedfernandotonon/QtMeshEditor-modelsHF repo viakDefaultModelBaseUrl), 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), withpbrSynthStarted/Completed/Errorsignals 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 whenaiPbrAvailable()), the MCPgenerate_pbr_mapstool, and the CLIqtmesh 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-heightrequest succeeds offline; a normal/height request fails gracefully (exit 1, no output) when the model is missing or the binary was built withoutENABLE_ONNX. Sentry breadcrumb categoryai.assist.pbr_synth. Windows MinGW:ENABLE_ONNXstays 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.onnxfiles are hosted atfernandotonon/QtMeshEditor-models(kDefaultModelBaseUrl) and download on first use; override withQTMESH_PBR_MODEL_BASE_URL/ai/pbrModelBaseUrl, or setQTMESH_PBR_NO_DOWNLOADto 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), thePbrMapSynth/BackgroundRemovershape, 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).ensureModelBlockingguards a real event-loop race:ModelDownloader::startDownloademitsdownloadErrorsynchronously when another download is already active, so a handler'sloop.quit()would fire beforeexec()and be lost — hanging for the full timeout and then cancelling the other caller's download; thesettledflag skipsexec()in that case, and only OUR timeout cancels. Three consumers: (a) UV-island seam fill —MeshGenBaker::Result::coveragepublishes 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), andMeshGenPredictorconsumes it viaTextureInpaint::maskFromCoveragebehindOptions::inpaintSeams(CLI--inpaint-seams, MCPinpaint_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 toOptions::textureSize: xatlas picks its own atlas dimensions (59² for a 64² request), so a mask must be built fromtexture.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: CLIqtmesh material --texture <img> --inpaint --mask <m.png> [-o out] [--mask-dilate N], MCPinpaint_texture, and the Inpaint buttons in the paint panel + detached texture editor. Modelai_models/pbr/lama.onnx(~200 MB) downloads on first use (QTMESH_INPAINT_MODEL_BASE_URL/ai/inpaintModelBaseUrl; guardQTMESH_INPAINT_NO_DOWNLOAD). Hosting gotcha: theCarve/LaMa-ONNXrepo's plainly-namedlama.onnxis BROKEN (fails ORT shape inference on aDFTnode);lama_fp32.onnxis the working graph, re-hosted under the plain name.scripts/export-lama-onnx.pyrefuses to host a graph that does not load, emits non-finite values, alters unmasked texels, or leaves the masked region unchanged. Sentry breadcrumbai.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.TextureUpscaleris the Ogre-free core (reusesPbrMapSynth::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-modelMapenum withUpscaleX2/UpscaleX4, downloads the model on first use (same HF repo), runs, caches<stem>_upscaled_x{2,4}.pngnext to the source, and emitsupscaleStarted/Completed/Error. The Material Editor path is worker-threaded and reports state viaupscaleDownloading(first-run model fetch) /upscaleProgress(done,total)(per tile) /upscaleCompleted/upscaleError;cancelUpscale()flips a shared atomic that the tiling loop'sProgressFnchecks (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 viascripts/export-realesrgan-onnx.py(one-time, offline, NOT shipped). Surfaced via CLIqtmesh material --texture <low> --upscale {2|4} [-o <high>](CLIPipeline::cmdMaterialUpscale), the MCPupscale_texturetool, and "Upscale 2× / 4×" buttons in the Material Editor's Texture Properties panel. Sentry breadcrumb categoryai.assist.upscale. ONNX intra-op threads are set tohardware_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 —PbrMapSynthstays 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 coreCLIPipeline::llmDescribeMaterialToEntity(entity, prompt, modelName, error)resolves a GGUF model (the--model/modeloverride, else last-used / first available viaLLMManager::scanForModels+availableModels), drivesLLMManager::generateMaterialsynchronously through twoQEventLoops (model-load then generation — mirrors the SD texture CLI), strips markdown code fences, extracts thematerial <name>header, parses the script viaMaterialManager::parseScript,compile()s, honors apbr_workflowtag throughRTShaderHelper::applyPbrIfTagged, and binds the material to every submesh of the entity. The CLIqtmesh material <file> --describe "<prompt>" [--model <name>] [-o out](CLIPipeline::cmdMaterialDescribe) imports → applies → re-exports; the MCPdescribe_materialtool (MCPServer::toolDescribeMaterial, args{prompt, mesh?, model?, output_path?}) applies to the named/selected entity in-session and optionally re-exports whenoutput_pathis 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.cppalways compiles, so no#ifdef ENABLE_LOCAL_LLMguard is needed at the call sites (only the llama linking is guarded). Sentry breadcrumb categoryai.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, CLIqtmesh skin --algo skintokens|geodesic-voxel|inverse-distance, MCPcompute_skin_weightsalgoparam; "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, MCPauto_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 inbonesWithoutSeeds), one multi-source Dijkstra over solid voxels (26-conn) keeping the best K=8(bone, distance)pairs per voxel, then per-vertex(1/d)^falloffweighting. 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.SkinTokensfalls back to GeodesicVoxel when its models/ONNX are unavailable (Slice C). SkinWeightsPost (src/SkinWeightsPost.h/cpp, Ogre-free + unit-tested) runs after EVERY algorithm insidecomputeAndApply: 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 gainsalgorithmUsed,fallbackReason,bleedFraction(fraction of committed weights not geodesically local — 0 for GVB by construction),bonesWithoutSeeds. Sentry breadcrumbsai.assist.skin.<algo>.qtmesh rig --skinand MCPauto_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 inRTShaderHelper::initialize(bone cap 96; above-cap entities stay on the default path); Linear mode erases the material'sHS_SRS_DATAimprint (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 MCPset_skinning_display {mode}; mode tracked on the entity's UserObjectBindings; Sentryrender.skinning. Slice E — evaluation suite:SkinMetrics(src/SkinMetrics.h/cpp, pure-data: influence histogram, Laplacian weight-smoothness energy, LBS deform + signed mesh volume) andSkinEvaluate(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). CLIqtmesh skin --evaluate/--compare <ref>; acceptance fixtures inSkinMetrics_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 indocs/SKINNING_QUALITY.md; env-gated reference test viaQTMESH_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::SkinTokensruns 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-samplenum_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.dataexternal 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 reposkintokens/(~2.3GB, downloads on first use;QTMESH_SKINTOKENS_MODEL_BASE_URL/ai/skintokensModelBaseUrl, guardQTMESH_SKINTOKENS_NO_DOWNLOAD). ~12 min for 119 bones/90k verts on an M-series CPU — report saysalgorithmUsed: "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 temporaryTypeInfo. 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(exponent1+falloff*5, matchingEditModeController::applyVertexColorBrushso 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 overSkinWeightsPostfor normalize/smooth/limit-influences. The load-bearing primitive iswriteWeightHoldingTarget: 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 usesetWeight+normalizeRowfor a user-driven weight change —normalizeRowrescales 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::fallbackBoneHandlenames 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 viaSkinEvaluate::extract, edit in memory as the user strokes, write back on a debouncedQTimer::singleShot(0)tick. The write path is deliberately the IN-PLACE one —clearBoneAssignments+addBoneAssignment* +_compileBoneAssignments()per owner, with NOEntity::_initialise— because that re-packs BLEND_INDICES/WEIGHTS into the SAME vertex buffer the liveSkeletonInstancealready references; swapping VertexData out from under a live skeleton is what shatters the on-screen mesh (see the UV-unwrap notes)._compileBoneAssignmentsis 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 matchSkinEvaluate::extractexactly;vba.vertexIndexis OWNER-LOCAL while the weights array is globally indexed. Undo viaWeightEditCommand(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 fromEditModeController, 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 wholeManualObjectand 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 thePropertiesPanelControllerselection/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 insideMaterialManager::getByNameon re-enabling paint. Viewport routing lives inTransformOperator(press/move/release whenmTransformState == 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 thanTexturePaintController::hitTestLocalPoint, which early-returns unless a TEXTURE paint session exists and therefore silently skipped every dab. Cursor:OgreWidget'sbrushCursorActive()predicate covers both paint modes at all four cursor sites. Sentryscene.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), apply1/dist^falloffweighting, 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. OptionalskipUnweightedBonesfilters Mixamo helper bones.replaceExisting=falseenables a merge mode for "fill in missing weights" workflows. Surfaced viaqtmesh skin --max-influences N --falloff F -o out, MCPcompute_skin_weights, and the Animation Mode → Mode Tools → "Skinning" section → "Compute Skin Weights…" button (qml/SkinWeightsDialog.qml, driven bySkinWeightsControllersingleton). 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 tohasSkinnedSelectionso it disables on static (skeleton-less) meshes. The GUI path runs throughComputeSkinWeightsCommand(src/commands/) so the auto-skin is undoable (Ctrl+Z): the command snapshots every submesh'sVertexBoneAssignmentList(+ the mesh-level shared list) before the firstredo, runscomputeAndApply, and onundorestores the snapshot and calls_compileBoneAssignmentsto 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 categoryai.assist.skin_weights. A future slice can plug libigl BBW in behind-DENABLE_LIBIGL_BBWfor 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 anOgre::Skeleton(parent-relative bone positions,setBindingPose), binds viamesh->_notifySkeleton+entity->_initialise(true)— the re-initialise is REQUIRED or both exporters (FBXExporter and the Assimp glTF/FBX path gate onentity->hasSkeleton()) silently drop the new rig. Pure-data core (templateJoints/fitTemplate) is unit-tested without GL. Surfaced viaqtmesh rig <file> [--skeleton T] [--skin] [--up-axis x|y|z] -o out(CLIPipeline::cmdRig, optionally chainsSkinWeights::computeAndApplyfor one-click rig+skin), MCPauto_rig{template, skin?, up_axis?, output_path?}(MCPServer::toolAutoRig), and the Animation Mode → Mode Tools → "Rigging" section → "Auto-Rig…" button (qml/AutoRigDialog.qml, driven byAutoRigControllersingleton, gated onhasRiggableSelection— a static/skeleton-less mesh; already-rigged meshes show the "Skinning" section instead). Sentry breadcrumb categoryai.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 isAutoRig::fitTemplateWithMarkersand it does coherent inference, not per-marker patching: it runsfitTemplatefor 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-returnsfitTemplateunchanged;report.markersAppliedcounts 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→layChainlays 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→layLeganchors 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)).layChainis 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.markersAppliedcounts the placed ones; an empty marker set is bit-identical tofitTemplate). The viewport flow lives inAutoRigController(marker-session state machine:beginMarkerPlacement/skipCurrentMarker/undoLastMarker/cancelMarkerPlacement/commitMarkerRig); clicks are routed in byTransformOperator::mousePressEvent(checked before the knife/select paths whenmarkerMode()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-yellowPT_SPHEREoverlays. The whole UI is inline in the Inspector's "Rigging" section (riggingToolsComponentinqml/PropertiesPanel.qml) — there is no separate dialog (the oldAutoRigDialog.qmlwas 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; whilemarkerModeis active it swaps to the per-marker guidance label + Skip/Undo/Cancel/"Rig from markers" controls. Rig state + therunAutoRig/runMarkerRighelpers live on thePropertiesPanelroot; the section'sonSectionVisibleChangedcancels any active marker session if the section disappears (mode change / deselect / re-rig), replacing the dialog's oldonClosingcancel. 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 (seeTHIRD_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 fromsrc/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 +undiscretizeare public statics (UniRigPredictor::detokenize/undiscretize) so they're unit-tested without ONNX. Everything isENABLE_ONNX-guarded; two model files (AppData/ai_models/unirig/{encoder,decoder}.onnx) download on first use viaModelDownloader(ensureModelBlocking, 180s timeout, returns the encoder path only when BOTH exist; base URL overrideQTMESH_UNIRIG_MODEL_BASE_URL/QSettings ai/unirigModelBaseUrl, offline guardQTMESH_UNIRIG_NO_DOWNLOAD). UniRig falls back to Pinocchio (logged inreport.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 HFAutoModelForCausalLM+ 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/OUniRigPredictortargets. The three graphs ARE hosted (unirig/{encoder,decoder,embed}.onnx, ~1.44 GB). #1025 post-mortem:--algo unirigfell 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 tworesblocks.2weight matrices as NaN/±3e38.UniRigPredictor::expectedSha256()now carries the three HF LFS oids andensureModelBlockingroutes every file throughModelFetchwith them (default hosting only), so a corrupt copy is detected, deleted and re-fetched before the graph is loaded; the NaN-latent guard inpredictstays 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 aGatherbounds 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 CLIqtmesh rig <file> --algo pinocchio|unirig(CLIPipeline::cmdRig;rignetaccepted as a deprecated alias), MCPauto_rigalgoparam (MCPServer::toolAutoRig), and the Inspector Rigging-section Algorithm segmented picker; the report carriesalgorithmUsed+fallbackReason, and the Sentryai.assist.auto_rigbreadcrumb records thealgo. Frozen query sampling (#1046) and how the C++ compensates: the export traced the Michelangelo encoder's farthest-point sampling into constants —Constant_174holds 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. SoOptions::querySamplingdefaults toBoth: 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|bothoverrides;Result::querySampling/alternativeJointsrecord the outcome. A re-export with dynamic FPS would make the single faithful ordering sufficient again. Category-aware joint naming (#1013, slice 1): UniRig'sclstokens 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:labelJointsAnatomicallystampedHips/Neck/Head/LeftFooton a car (its LONG axis reads as the spine) andRightArm_3on 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 — andUniRigPredictor::Options::labeling {Auto, Humanoid, Generic}(default Auto) applies humanoid names only when that holds, elselabelJointsGeneric(root,bone_01, … — unique by construction).applyLabelingreturns which was used;Result::labeling→AutoRig::Report::jointLabeling→ JSONjointLabeling(CLI--json, MCPauto_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 bipedgets 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.objcame 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. HenceAutoRig::Template::Vehicle(--skeleton vehicle, aliascar; in the Inspector list; MCPtemplate:"vehicle"):kVehicle= Chassis root → FrontAxle/RearAxle → four wheels, NO slab recentring (it would pull wheels onto the centre line);AutoRig::fitVehiclebox-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 (fallbackReasonsays 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) withAlgorithm::InverseDistance— every vertex follows exactly one bone. Surfaces:qtmesh rig … --rigid(implies--skin),qtmesh skin … --rigid, MCPauto_rig {rigid:true}; and it is IMPLIED by the vehicle template on every surface (AutoRig::templateIsRigid; GUIAutoRigController::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 onhumanoidand a user picking UniRig + car got the lying-human rig twice over (Both mode) with no way to reachvehicle. The picker now stays visible in UniRig mode as "Skeleton type (category hint)" with a one-line per-template explanation. Tests:AutoRig_test.cppAutoRigVehicle.*(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 earlyqml/AutoRigDialog.qmlreference above is stale — the UI is inline inqml/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 gradientS = [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 perFaceCap::kBlendshapeNames. The shared sparse solver isSparseSolve.{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, andattachShapes()splits the deltas back per submesh handle and attaches them asOgre::Pose+VAT_POSEmorph targets viaAddMorphTargetCommand(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 CLIqtmesh facerig <file> -o <out> [--max-shapes N] [--max-residual PCT] [--json](CLIPipeline::cmdFaceRig), MCPadd_arkit_blendshapes(MCPServer::toolAddArkitBlendshapes,{max_shapes?, max_residual_pct?, output_path?}, heavy), and the Inspector Vertex Morph Animation → "✨ Add ARKit Blendshapes (AI)" button (FaceRigControllerQML_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 onhasMeshSelection, shows "Downloading…/Fitting…" status). Template = ICT-FaceKit (MIT,THIRD_PARTY_AI_MODELS.md), packed byscripts/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; overridesQTMESH_FACERIG_MODEL_BASE_URL/QSettings ai/facerigModelBaseUrl; offline guardQTMESH_FACERIG_NO_DOWNLOAD). Sentry breadcrumbai.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 writesmesh.extras.targetNames(the Blender/Godot/three.js convention) fromaiAnimMesh::mName— but ONLY under the export propertyGLTF2_TARGETNAMES_EXP, whichMeshImporterExporter::assimpExportProperties()now sets at all threeAssimp::Exportersites (exporter,exportCurrentPose,sceneExporter). Its importer reads them intomName, whichMeshProcessorprefers, so names round-trip with no sidecar; the<file>.arkit.jsonsidecar 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, andconstellationResidual(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, reusesFaceCapPose::solve) solves a Horn similarity (proper rotation, never a reflection) from the anchors;buildFaceRigrotates 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°).constellationResidualnow delegates torotationAwareResidual, 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'sfaceDirLocal, returned bybuildLandmarkAnchors(..., &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/AttachReportcarryorientationSource(anchors|face_dir|none) + angle, echoed in CLI/MCP JSON and aai.assist.face_rigbreadcrumb. 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; andensureLandmarkModelOnce()downloadsface_landmarks.onnxat 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).FaceRigControllerreserves 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) soNpzReaderwalks the zip directory and.npyheaders directly (no zlib), andWavReaderwalks 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 oneVAT_POSEtrack 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 (writeWeightKeyOnreuses tracks and only overwrites coincident times, and clip length only grows).mouthCloseconvention trap: ARKit defines it as a counter-shape tojawOpen, but NVIDIA's character authors it with the OPPOSITE sign (cosine withjawOpen+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 ownbsWeightMultiplierstable, keyed by pose NAME since the npz is a zip and its entry order is arbitrary (poseNamesinside it is authoritative). Surfaces: CLIqtmesh lipsync(AudioToFace::LipsyncCLI, deliberately NOT behindENABLE_MOCAP— animating from a file must not require the webcam/Qt-Multimedia stack), MCPgenerate_lipsync(heavy, advertised only on anENABLE_ONNXbuild), and the Inspector "🎙 Lipsync from Audio (AI)…" button (LipsyncController: model fetch on the MAIN thread before the worker starts —ModelFetchdrives the singletonModelDownloaderand its QNAM/timers, which live there — then solve on a worker, commit viaLipsyncClipCommandas ONE undo step; a barebeginMacroaround 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. Sentryai.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; defaultmaxAngleDeg=25°), (2) quad shape (deviation of interior angles from 90°; defaultshapeToleranceDeg=65°), (3) aspect ratio (longest/shortest edge; defaultmaxAspectRatio=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 throughEditableSubMesh::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 theAlgorithmenum (onlyTrianglePairimplemented; futureQuadriFlow/InstantMeshesslot in here). Surfaced viaqtmesh retopo --target-faces N --max-angle DEG -o out, MCPretopologize, and the Material Mode → Mode Tools → "Quad Retopology…" button (qml/QuadRetopoDialog.qml, driven byQuadRetopoControllersingleton). Sentry breadcrumb categoryai.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 globalPartvocabulary: 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;windowis 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) viaresolveCategoryBlocking()— classifier unavailable/offline → Body (the pre-B2 behaviour). Local model channels map to global parts viacategoryChannelMap(); 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 indocs/MESH_SEGMENTATION_STRATEGY.md; SmolVLM stays a follow-up GUI "identify/name" assist). CLI--category, MCPcategoryarg; all models download from HFsegment/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):segmentGeometricdoes 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/fallbackReasonreport 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()downloadsmeshseg.onnxtoAppData/ai_models/segment/(overrideQTMESH_SEGMENT_MODEL_BASE_URL/QSettings ai/segmentModelBaseUrl; offline guardQTMESH_SEGMENT_NO_DOWNLOAD; non-ONNX#ifndefguard) — the #408/#409 pattern. Pure-data helpers (connectedComponents,facesFromVertexLabels) are unit-tested without Ogre/GL. Split-cleanup passes (#863, default ON viaOptions::cleanupIslands, applied after labelling on BOTH the model and geometric paths, then vertex labels reconciled viavertexLabelsFromFaces):smoothLabelBoundariesstraightens 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);cleanupLabelIslandsreabsorbs 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 viabuildFaceAdjacency), so they clean the exact thing PartOps split routes by.levelLimbCut(default ON viaOptions::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-hversion 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 narrowerlevelLimbCut, kept for future refinement. Surfaced via CLIqtmesh segment <file> [--json] [--no-model] [--up-axis x|y|z](CLIPipeline::cmdSegment— text per-part counts or full label arrays), the MCPsegment_meshtool (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 viaselectFaceso the existing highlight refreshes). Sentry breadcrumbai.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) viascripts/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 undersegment/(seeTHIRD_PARTY_AI_MODELS.md+docs/MESH_SEGMENTATION_STRATEGY.mdfor the v1 failure analysis, accuracy numbers, and the multi-category roadmap). Three-tier dispatch inselectByPart: (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 appliesOptions::upAxisby 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.jsonruns the rig-prior path and writes the normalised point cloud + EXACT per-vertex labels (schemaqtmesh-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::rigPriorPartLabelsis 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 annx×ny×nzgrid of control points, drag points in the viewport, the enclosed vertices bend live (Sederberg-Parry free-form deformation).Lattice::Gridis 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, makesresetbit-exact, and lets the same lattice replay on another asset.LatticeController(QML_SINGLETON, Object mode,PartOpsControllerpattern) owns the session:beginSessionloads anEditableMeshfrom the selected entity, snapshots per-submesh rest positions, builds the grid fromEditableMesh::calculateBounds(); every edit runsapplyDeform→setVertexPositionfor every vertex (ALL submesh copies —EditableMeshduplicates shared vertex data into each submesh andcommitToEntityuploads only submesh 0's copy) → aQTimer::singleShot(0)-coalescedrecalculateNormals+commitToEntity(the vertex-paint flush idiom: moves arrive faster than a 90k-vertex commit). Two undo levels:LatticeGridCommandper drag/reset/load/resolution/interpolation change INSIDE the session — it snapshots the wholeqtmesh-lattice-v1document before/after (so a resolution change, which reshapes the point array, is as undoable as a drag), lives on a SESSION-LOCALQUndoStack(LatticeController::m_sessionUndo, attached viaUndoManager::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 withm_inUndoReplaywhen 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.LatticeApplyCommandis the BAKE: dense[submesh][vertex]rest positions + REST NORMALS + deformed positions, BOUND to theOgre::Mesh*it was recorded on (a same-name replacement with a compatible layout is refused, not overwritten),alreadyAppliedskips the first redo, resolves the entity by NAME, callsabandonSessionFor(name)first (a live session's rest snapshot would no longer describe the mesh), writes throughEditableMeshin 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 viam_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 sameOgre::Mesh(m_meshIdentity); anything else (destroyed, or swapped under its node the waySplitMeshCommanddoes) ends the session before any dereference — never trustm_entityraw (review P1). The overlay node/objects are explicitly NAMED (LatticeOverlay_<sid>…) because Ogre registers only named nodes, anddestroyOverlaychecks liveness by name. Entering Edit Mode cancels the session (Edit Mode owns the same vertex buffers). Viewport:TransformOperatorroutes press/move/release tobeginDrag/updateDrag/endDragwhilesessionActive()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 viaupdateHover;mTrackingEnable/cursor gain a lattice term. Picking is screen-space nearest-within-12px (worldToScreenviaproj*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-dragmBoneDragGizmoOriginlesson — anchoring to the live point drifts). a press that misses every point starts a RUBBER-BAND box select (reuses TransformOperator'sm_pSelectionBox; release inside 3 px = plain click → deselect) viaselectPointsInRect; 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_LISTwires + threeOT_POINT_LISTsections (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 —ObjectItemModelstatic_casts attachments toEntity*),RENDER_QUEUE_OVERLAY, query flags 0. Persistence:qtmesh-lattice-v1JSON (Grid::toJson/fromJson, strict — bad resolution/short points/unknown interpolation rejected) via GUI Save/Load Lattice… (saveLatticeRequested/loadLatticeRequested→MainWindowruns theQFileDialog, 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 (latticeDeformComponentinqml/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); MCPlattice_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 viasetPoints) →lattice_apply/lattice_cancel,lattice_get, and the one-shotlattice_deform {lattice | lattice_path, entity_name?, output_path?}(heavy; undoable viaLatticeApplyCommandwithalreadyApplied=false); JSON coordinates are validated as finite numbers everywhere (Grid::fromJson—QJsonValue::toDoublewould read a string as 0); CLIqtmesh lattice <mesh> --apply <lattice.json> -o <out>/--info [--resolution nx,ny,nz]/--info <lattice.json>(CLIPipeline::cmdLattice,latticeis inAppLaunchHandler's subcommand list).LatticeController::deformEntityWithGridis the shared one-shot core (deforms CURRENT positions — for a rest asset that equals the session result). AI capabilitymesh_deforminAICapabilityRegistry. Sentrymesh.lattice.begin|apply|cancel|reset|drag|resolution|interpolation|save|load. No gamification cluster — the cloudDISCOVERY_FEATURESlist 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): turnsMeshSegmenteroutput into real authoring ops.SubMeshOpsis the Ogre-buffer-free, unit-tested core (operates onEditableSubMeshdata so split/join/explode/solidify math is headless-testable):groupFacesByLabel(face labels → stableFaceGroups),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 suffixeshead,head.1…),joinParts(#862 — merge parts baking world transforms into positions/normals; same-material submeshes coalesce),explodeOffsets(#862), andsolidify(#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 thecapOpenBoundariescut-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, MCPprepare_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.)PartOpsMeshis the Ogre adapter: reads an entity into attribute-completeEditableSubMeshes (same submesh-then-local triangle order asAutoRig::gatherGeometry, sofaceLabelsmap 1:1), runs the split, and builds a freshOgre::MeshviaEditableMesh::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 emitsgetSubMeshNameMapnames → AssimpaiMesh::mName→MeshProcessornameSubMesh) and show in the Scene tree (SceneTreeModelprefers the registered submesh name over the positional index). Surfaces: CLIqtmesh 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→ undoableSplitMeshCommand; MCPsplit_mesh_by_segments(same command).SplitMeshCommandswaps the whole mesh on the scene node — a submesh-count change can't go through the in-placeEditMeshTopologyCommand/resizeEntityBufferspath; Ctrl+Z restores the fused mesh. It clears the SelectionSet's entity + sub-entity references BEFORE destroying the old entity (they only auto-clean onsceneNodeDestroyed, 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 inEditModeController(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 abovePartOpsMesh(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-submeshOgre::Mesh(preserving attributes/material/skeleton+bone-assignments, part name viagetSubMeshNameMap) and computes an outwardSubMeshOps::explodeOffsetsper part (from part-vs-assembly centroids × distance × assembly diagonal);joinEntities(entities, base)reads each entity's submeshes + its node's_getFullTransform()intoSubMeshOps::JoinParts (world transform baked into positions, inverse-transpose into normals/tangents) and merges viajoinParts(same-material coalesce; skeletons NOT reconciled — join yields static geometry).ExplodePartsCommand(src/commands/): redo destroys the fused node and creates N sibling part nodes atsrcTransform + 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 viaManager::reparentNode+ explicit local-TRS restore) and reject nodes with child nodes (a subtree they don't serialise) with a clear error.PartOpsMesh::readSubMeshesprefers 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::joinPartsreverses 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 (partOpsExplodeJoinComponentinqml/PropertiesPanel.qml) →PartOpsController::explodeSelected(distance)/joinSelected(), gated on newcanExplode(one multi-submesh selection) /canJoin(2+ selected) props. Breadcrumbsmesh.parts.explode/mesh.parts.join. Slice E (#864) — CLI/MCP parity: CLIqtmesh 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::sceneExportermulti-node glTF; the original imported node is destroyed first so the exported scene isn't doubled by an intact un-exploded copy); MCPexplode_mesh_parts({entity_name?, distance?}, undoable viaExplodePartsCommand) andjoin_mesh_parts({entity_names?}— omit to join all mesh entities; undoable viaJoinPartsCommand), both registered heavy + mapped to theai_assistgamification cluster.segment_meshalready returnsface_labelsalways (the epic'sreturn_face_labels). Print-split/pegs were descoped (removed this epic). Breadcrumbsmesh.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.solidifyoffsets 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 loopb→a→ai→bicancels both the outera→band 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 →SplitMeshCommandsolidify param →SplitOptions::solidifyParts; CLIsegment --split-parts --solidify; MCPsplit_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 (needsb→a) and the inner dangling edge (needsai→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/capPartscut-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+ MCPexplode_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 (allmesh.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, HFstabilityai/TripoSR). The fifth ONNX consumer (after #404/#408/#409/#410); all files live in thesrc/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 — encoderimage[1,3,512,512]→scene_codes[1,3,40,64,64](triplane) and per-point decoderscene_codes+points[1,P,3]→density[1,P,1],color[1,P,3]— GENERATING query points per chunk (not the wholeres³grid up front — that would OOM at 512) and extracting the surface withMarchingCubes(native Lorensen impl, public-domain tables, zero deps; TripoSR'storchmcubesis torch/GPU-only). Surface = MC ondensity − thresholdat iso 0 (threshold 25.0, radius 0.87); our MC is inside-positive soextract()emitsv0,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 anOgre::Mesh(POSITION + accumulated per-vertex NORMAL + optional DIFFUSEVET_COLOURwith 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'sresize_foreground). Modelai_models/rembg/u2net.onnx(QTMESH_REMBG_MODEL_BASE_URL/ai/rembgModelBaseUrl; guardQTMESH_REMBG_NO_DOWNLOAD); falls back to the raw image if unavailable. EverythingENABLE_ONNX-guarded; no fallback (generative), so a non-ONNX build / missing model returns a clear error (never crashes). Models underai_models/triposr/download on first use (ensureModelBlocking(q);QTMESH_TRIPOSR_MODEL_BASE_URL/ai/triposrModelBaseUrl; guardQTMESH_TRIPOSR_NO_DOWNLOAD), OR can be pre-downloaded from the AI Settings modal's Download tab (tier picker + progress bar). Export isscripts/export-triposr-onnx.py(offline, not shipped;transformers==4.35.0,torchmcubesstub, frozen ViT pos-encoding; emits the int8 variant unless--no-quant— seedocs/IMAGE_TO_3D_SPIKE_764.md). Surfaced via CLIqtmesh generate3d <image> [-o out.glb] [--resolution 16..1024] [--no-color] [--remove-bg] [--quality fp32|int8](CLIPipeline::cmdGenerate3d), MCPgenerate_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 breadcrumbai.assist.image_to_3d. Verified end-to-end on macOS. Models are HOSTED on thefernandotonon/QtMeshEditor-modelsHF repo (triposr/triposr_encoder.onnx+triposr_encoder_int8.onnx+triposr_decoder.onnx,rembg/u2net.onnx) viascripts/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 inMeshRefine_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 aColorSamplercallback;MeshGenBaker_test.cpp).MeshGenBuildergained 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 nameddiffuse_mapTUS. Bake failure falls back to vertex colours withResult::warningset (never fails the generation). PBR stage (d, ON by default):MeshGenBuilder::BuildOptions::generatePbrMapschains #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 (canonicalnormal_map/roughnessTUS +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 intoMeshGenController::generateSelected; CLI--no-smooth --no-refine --no-bake-texture --no-pbr --texture-size N --upscale-texture; MCPsmooth/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 indocs/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·σ, updatex += (σᵢ−σᵢ₊₁)·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 fromQuality::Int8. Models underai_models/triposg/download on first use (QTMESH_TRIPOSG_MODEL_BASE_URL/ai/triposgModelBaseUrl; guardQTMESH_TRIPOSG_NO_DOWNLOAD); clean "not hosted yet" error until the export is run + hosted. Surfaced via CLI--backend triposr|triposg --flow-steps N, MCPbackend/flow_stepsargs, and the GUI Backend dropdown (the step list gains a "Denoise (flow steps)" row viaStage::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; thequalityparam now only selects the TripoSR tier used for the colour bake). (2) Colour — TripoSG has no colour decoder, soMeshGenPredictor::colorizeWithTripoSRbakes 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=falseskips 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 viaQTMESH_TRIPOSG_COREML_DECODER=1(default CPU — per-call kv re-upload made GPU slower);--guidanceknob (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-unitxatlas.cppvendored via FetchContent and wrapped in an inlineadd_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 fromxref(input vertex id) and overwriting the target UV channel withxatlas::Vertex::uv / atlas.{width,height}. Skinned-mesh bone assignments survive the seam splits because we rebuildSubMesh::BoneAssignmentListagainst the new vertex IDs via xref; for shared-vertex meshes the source assignments come fromMesh::getBoneAssignments(), notSubMesh::getBoneAssignments(). Surfaced viaqtmesh uv --unwrap/--info, MCPauto_uv_unwrap/uv_unwrap_selection, and the Material Mode → Mode Tools → "Auto UV Unwrap…" button (qml/UvUnwrapDialog.qml, driven byUvUnwrapControllersingleton). Sentry breadcrumb categorymesh.uv.unwrap. The unwrap also erasesqtme.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 activeOgre::SkeletonInstancecaches the hardware blend buffer and picks up stale state on the first frame after the swap. The GUI path snapshotsvertexData/indexData/mBoneAssignments/blendIndexToBoneIndexMapfor every submesh + the mesh's shared maps, callsunwrapEntityKeepingOriginals(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 —_compileBoneAssignmentsis 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 destructiveunwrapEntitysince 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 inUVTransform,UvProject,UvSeamData/UvSeamOps, and undo viaUVEditCommand/UvSeamCommands. Headless parity (#465) is centralized inUvPipeline(src/UvPipeline.h/cpp):analyzeEntity(channel info + island count + AABB overlap upper bound),projectEntity,parseSeamEdgeList/setSeamsOnEntity,unwrapEntity, andunwrapTriangles(face-mask partial unwrap). CLI:qtmesh uv --info,--project,--set-seams,--unwrap. MCP:uv_info,uv_project,uv_set_seams,uv_unwrap_selection(+ existingauto_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):Gtranslate,Rrotate,Sscale,Ppin toggle, projection buttons in toolbar;Tabexits back to Object mode. - ExportOptimizer (
src/ExportOptimizer.h/cpp, issue #399): Pipeline that runsmeshopt_optimizeVertexCache→meshopt_optimizeOverdraw(threshold 1.05) →meshopt_optimizeVertexFetchRemapon every submesh of an entity. Surfaced through the Inspector validation flow — the "Optimize Geometry (cache + overdraw + fetch)" button inPropertiesPanel.qmlruns it viaMeshValidator::optimizeVertexCache. NOT hooked intoMeshImporterExporter::exporterby 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 usesuseSharedVerticessince remapping shared verts would scramble other submeshes' indices.qtmesh info --jsonincludessubmeshAcmr[]per submesh so downstream tooling can decide whether to recommend re-optimization. Sentry breadcrumb categoryai.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-arcslerpQuatfor 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+fallbackReasontell 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 aChannellayout (Scalar/QuatStart/QuatCont) so it's unit-tested without Ogre/GL.MotionInbetween::ensureModelBlocking()downloadsrmib.onnxon first use toAppData/ai_models/inbetween/(overrideQTMESH_INBETWEEN_MODEL_BASE_URL/QSettings ai/inbetweenModelBaseUrl; offline guardQTMESH_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 anInbetweenResult(keyframesInserted / tracksAffected / usedModel / fallbackReason). Surfaced via CLIqtmesh anim <file> --in-between --gap-frames N [--start-time S] [--end-time S] [--no-model] [--animation NAME] [-o out](CLIPipeline::cmdAnim), the MCPmotion_in_betweentool (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; emitsinbetweenStatus). Sentry breadcrumb categoryai.assist.in_between. Canonical skeleton + retargeting: the model is trained on a FIXED 22-joint CMU core-body skeleton (C=220), soAnimationMerger::inbetweenAnimationmaps the entity's track bones onto those 22 roles viaMotionInbetween::canonicalIndexForBone()(handles Mixamomixamorig:*, genericL_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 thefernandotonon/QtMeshEditor-modelsHF repo underinbetween/, downloads on first use (seeTHIRD_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 (seedocs/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 viaAnimationMerger::applyMotionClip→MotionInbetween::canonicalIndexForBone(the SAME 22-joint canonical mapping as #409).MotionLibraryis Ogre-free + unit-tested (MotionLibrary_test.cpp): parsesqtmesh-motion-library-v1/v2JSON (per-frame, per-joint canonical quats; v2 adds an optional 22-entrycmuRestWorldblock) + keyword matching. Library downloads on first use toAppData/ai_models/motion/(overrideQTMESH_MOTION_LIBRARY_BASE_URL/QSettings ai/motionLibraryBaseUrl; offline guardQTMESH_MOTION_NO_DOWNLOAD) — built offline byscripts/build-motion-library.py(10 actions: walk/run/jump/dance/march/kick/punch/wave/climb/idle, ~0.9 MB), hosted on thefernandotonon/QtMeshEditor-modelsHF repo undermotion/. 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 v2cmuRestWorldper-joint world-restWcmu). The exact per-bone formula islocal(f) = parentWorld⁻¹ · (Wcmu · clip(f) · Wcmu⁻¹) · parentWorld · bind, wherebind= 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 ingetInitial{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). TheWcmu·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 (nocmuRestWorld) fall back toWcmu=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 CLIqtmesh anim <file> --generate "<prompt>" [--duration N] [-o out](CLIPipeline::cmdAnimGenerate), the MCPgenerate_motiontool (MCPServer::toolGenerateMotion, args{prompt, entity_name?, duration?, output_path?}, registered heavy), and the Animation panel "Generate from text" control (qml/AnimationControlPanel.qml→AnimationControlController::generateMotion, emitsgenerateMotionStatus). Sentry breadcrumbai.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::generateconsumes 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 — becauseMotionComposeris reachable only on the template path. All three surfaces now checkMotionComposer::promptHasMultipleSteps(a library-free segmenter sharingparse'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 / MCPmodel_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.--modelmay appear anywhere in the argument list (it used to be honoured only AFTER--generate, so--model --generate "..."silently ran the template path);--modelwithout--generateis 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 frameCt, reusing the retarget'sreadTargetBindFramehelper), mirrored per side so+degwidens both arms. Keyframes are deltas on the bind pose (Ogre'sNodeAnimationTrack::applyToNodepost-multiplies onto the reset bone), so the world swingSis folded into each keyframe asL·kfwhereL = Wbind⁻¹·S·Wbind(conjugation into the bone's bind-local frame). Gotcha:TransformKeyFrame::setRotationdoes NOT invalidate the track's interpolation caches, so after editing keyframes you MUST calltrack->_keyFrameDataChanged()or the nextapply()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 onbone[0]'s UserObjectBindings (keyqtme.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:+45leaves the clip at +45, a later−45leaves it at −45 (net = −45), and onlyadjustArmSpace(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.applyMotionCliperases the entry when it (re)creates agenerated_*clip so a regenerated clip starts unadjusted.migrateArmSpaceKeymoves the entry on rename — called from BOTHAnimationMerger::renameAnimation(CLI/merge) andSkeletonTransform::renameAnimation(GUI) so a renamed widened clip keeps its value. Surfaced via CLIqtmesh anim <file> --generate "<p>" --arm-space <deg>and standaloneqtmesh anim <file> --arm-space <deg> --animation <name> -o out(CLIPipeline::cmdAnim/cmdAnimGenerate), MCParm_spacearg ongenerate_motion(response echoesarm_space_applied) + standaloneadjust_arm_spacetool (MCPServer::toolAdjustArmSpace— edits the mesh's MASTER skeleton sooutput_pathexport 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 inAnimationMerger_test.cpp(swing angle, mirrored per-side direction, absolute/idempotent viacurrentArmSpace, 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 (
CLIPipelinefacingMode, 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 metricqtmesh anim <file> --facing --animation <name>plays the clip, samples the hip's world-forward (fwd = left×upfrom 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. AflipAnimationFacing180°-turn toggle (CLI--flip-facing, MCPflip_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-roundedos-remapMin/Max, bottom half(n+1)/2normals; 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 — AlembicVAT_POSEstream),Morph(the morph weight clip, defaultMorphAnimationManager::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)/2in RGBA with hemisphere continuity;p' = q*(p−pivot)+pivot_frame; per-chunkmaxResidualin 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. NeedsaddSoftwareAnimationRequest(true)AND the_notifyDirty+_fireFrameRenderingQueuedbumps per frame (without them every row equals row 0). Rigid refuses shared vertex data (a chunk must own its range). EncodingsbitDepth8/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 viaMinimalEXR::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 shipsopenvat_rigid.gdshaderonly, never the per-vertex shader, which would misread a chunk texture). Surfaces: CLIqtmesh vat(--mode/--encoding/--target, mode-aware entity pick, rigid--emit-uv2writes the chunk column into UV2.x viaemitGltfUv2'scolumnOverride), MCPbake_vat(mode/encoding/target/include_shaders;animoptional for morph), Inspector Animation-mode "VAT" section (Mode picker fromVATBakerController::availableModes→animationsForMode, Encoding + Target pickers). Breadcrumbsfile.exportcarryvat_mode=<id>. Two bugs found by the ICT lipsync parity test (fixed): (1) Ogre wrapsAnimationState::setTimePosition(t)withfmod(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)readGltfVerticesrefused the whole glTF when ANY buffer was a base64data: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 viaAnimationState::setTimePosition+_updateAnimationbefore 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 viaqtmesh isometric, MCPgenerate_isometric_sprites, and Animation Mode → Mode Tools → "Export Isometric Sprites…" (qml/IsometricSpritesDialog.qml,IsometricSpritesController). Sentry breadcrumb categoriesfile.export/ai.tool_call. - FBX LOD export gotcha:
FBXExporterprefers the cachedqtme.faces.<i>n-gon binding (set up by quad-migration #326) overSubMesh::indexData. The CLIlodper-LOD export path inCLIPipeline::cmdLodtemporarily 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.
- 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 inmainwindow.cpp,TransformOperator.cpp, andMCPServer.cpp. - Unit tests. Add Google Test unit tests for new functionality. Test files live alongside source in
src/with the_test.cppsuffix (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_LLMor skip gracefully. - Tests must work under Xvfb (headless X11) — avoid assumptions about a real display.
- Features that depend on optional components (e.g., local LLM / llama.cpp) may not be available in the test environment — guard with
- macOS:
macBundlePath()returns.appbundle root (notContents/MacOS/). Ogre resource paths inresources.cfgresolve relative to this. AGL framework stub created at configure time for newer macOS. - macOS Homebrew CLI symlink: Homebrew installs
/opt/homebrew/bin/qtmesheditoras a SYMLINK straight to the binary insideContents/MacOS. Launched that way, Qt resolvesapplicationDirPath()to the symlink dir (/opt/homebrew/bin), never findsContents/Resources/qt.conf, and can't locate the bundled Qt plugins / QML modules underContents/PlugIns→ the GUI opens but every QQuickWidget (Inspector, mode bar, Context panel) renders BLANK WHITE. Double-click /openworks (Launch Services supplies the bundle context).main.cpp::fixBundlePathsForSymlinkLaunch(argv[0])(macOS-only, runs beforeQApplication) follows the symlink to the real binary, and if it lives in…/X.app/Contents/MacOSsetsQT_PLUGIN_PATH+QML_IMPORT_PATH/QML2_IMPORT_PATHto the bundle'sPlugIns/PlugIns/qml; the post-QApplicationblock alsoaddLibraryPaths them. NB testing this by hot-swapping the binary in an installed.appbreaks 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::MaterialSerializer::parseScript()does not exist in Ogre 14.x. UseMaterialManager::getSingleton().create(name, group)and set properties via the API.- To serialize a material to string:
serializer.queueForExport(mat)thenserializer.getQueuedAsString(). Manager::getEntities()returns all attached objects — you must checkobj->getMovableType() == "Entity"before casting toOgre::Entity*(ManualObjects will crash otherwise).
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.plistcfg/version.txt.in— runtime version fileDEBIAN-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.
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.gltf2Key files:
Dockerfile— Ubuntu 24.04 base. Multi-arch viaARG TARGETARCH→COPY qtmesheditor_${TARGETARCH}.deb, so each platform in the buildx manifest installs its own.deb. Xvfb for headless GL. Build context must contain bothqtmesheditor_amd64.debandqtmesheditor_arm64.deb.docker-entrypoint.sh— Starts Xvfb, routes CLI commands via--cliflag, MCP commands toqtmesheditor.github/workflows/deploy.yml— the releasedocker-publishjob downloads both per-arch.debs (linux-binaries-amd64+linux-binaries-arm64artifacts from thebuild-linuxmatrix) anddocker buildx build --platform linux/amd64,linux/arm64pushes one multi-arch manifest..github/workflows/docker-publish.yml— Manualworkflow_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 itsbuild-n-cache-assimp-linux/build-n-cache-ogre-linuxdependencies) is astrategy.matrixover{amd64: ubuntu-latest, arm64: ubuntu-24.04-arm}— arm64 builds on GitHub's native arm64 runner (no QEMU), producingqtmesheditor_arm64.deb. Qt for arm64 isarch: linux_gcc_arm64(install pathgcc_arm64); the multiarch lib triplet isaarch64-linux-gnu. Cache keys embedmatrix.arch(runner.os isLinuxfor both, so without it the two arches would clobber each other's assimp/ogre caches). The packagingseds the.debcontrolArchitecture:field to match.- Consumers pinned to one arch:
unit-tests-linuxrestores the-amd64-caches;snap-publishdownloadslinux-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
--cliexplicitly because the launcher script'sexecchanges 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.
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-publishjob 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.
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 warningKey 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/qtmeshpoints users to this repo
When to update action.yml:
- New CLI subcommand added → update
commanddescription - Subcommand flags change → update
optionsdescription - Docker image name/registry changes → update the
docker runcommand - 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).
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.