Skip to content

pull requests template - #3

Merged
fernandotonon merged 1 commit into
masterfrom
macOS-build
Jan 12, 2023
Merged

pull requests template#3
fernandotonon merged 1 commit into
masterfrom
macOS-build

Conversation

@fernandotonon

Copy link
Copy Markdown
Owner

No description provided.

@fernandotonon
fernandotonon merged commit 4941972 into master Jan 12, 2023
@fernandotonon
fernandotonon deleted the macOS-build branch February 4, 2023 20:36
fernandotonon added a commit that referenced this pull request May 13, 2026
CodeRabbit + Codex flagged 8 distinct issues across the CLI and MCP
optimize paths. All real; all fixed in this commit.

## CLI (cmdOptimize)

1. Negative decimation targets now rejected at parse time. Before:
   `--reduction -1` parsed, set decimateRequested=true with
   reduction=-1.0, silently fell into the "target equals or exceeds
   current count; nothing to do" branch. Now: rejected with a clear
   error before any work starts. Same guard on --target-tris and
   --target-verts. (CodeRabbit major)

2. Decimation that fails to apply for a positive reduction now returns
   exit 1 with an error message, mirroring cmdDecimate. Before: the
   stage report showed applied=false but the command still exited 0 —
   automation could not tell a decimation failure from a no-op. The
   stage report is still emitted on the way out so callers can see
   the partial work. (Codex P1 / CodeRabbit major)

3. simplify-anim now walks every skeleton in the loaded scene, not
   just the first one found. Multi-entity assets (a co-loaded
   animation-only skeleton, or multiple skinned entities sharing one
   file) used to leave every skeleton after the first untouched.
   De-duplication by skeleton-name keeps cross-entity shared rigs
   from being simplified twice. Summary reports the skeleton count.
   (CodeRabbit major)

4. Global qtmesh --help now lists the simplify tolerance flags
   (--simplify-translation-tol / --simplify-rotation-deg-tol /
   --simplify-scale-tol). They were buried in the cmdOptimize usage
   error block; users running `qtmesh --help | grep -i tol` couldn't
   find them. (CodeRabbit minor)

## MCP (toolOptimizeMesh)

5. Scene isolation. The MCP server runs inside the editor process, so
   `MeshImporterExporter::importer(...)` was appending the optimize
   target into the user's live scene. Before stage 1 ran, the
   entities list contained both the just-imported asset AND every
   mesh the user already had loaded — and the optimizer happily
   mutated all of them. Now snapshot the entity-pointer set before
   the import, subtract after, and operate only on the delta. A
   trailing RAII cleanup destroys those scene nodes via
   Manager::destroySceneNode on every return path so the user's
   scene returns to exactly the state it was in before — no leaked
   nodes on success, on error, or on a thrown exception. (CodeRabbit
   critical)

6. Wrap the entire stage pipeline in a try/catch boundary catching
   Ogre::Exception, std::exception, and `...`. Before, a throw from
   any of vertex-cache / decimate / simplify-anim / export
   propagated up through Qt's signal dispatcher and crashed the
   editor process. The importer call was already guarded; the broader
   boundary now covers the rest. (CodeRabbit major)

7. Same decimation-not-applied → error treatment as the CLI: return
   makeErrorResult instead of silent applied=false in the stage
   report. (Codex P1)

8. Multi-skeleton simplify (matches CLI fix #3 above).

9. file.import Sentry breadcrumb on the source load — symmetric with
   the file.export breadcrumb already present. (CodeRabbit major
   refactor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fernandotonon added a commit that referenced this pull request May 13, 2026
…(Phase 6 slice G) (#507)

* feat(opt): batch optimize pipeline — qtmesh optimize + MCP optimize_mesh (Phase 6 slice G)

Sequences the slice C / C4 / D optimizations end-to-end on a single
mesh asset and writes the result to -o <path>. Same loaded Ogre scene
flows through every stage with no intermediate file I/O.

## Stages

1. **vertex-cache** (slice C / VertexCacheOptimizer::analyzeEntity) —
   Forsyth reorder of every triangulated submesh. Reports before/after
   weighted ACMR and the number of submeshes rewritten.
2. **decimate** (slice D / MeshDecimator::decimateEntity) — single-pass
   reduction. Runs only when --reduction / --target-tris / --target-verts
   is supplied. Multi-entity scenes are rejected when decimate is
   requested (same one-entity contract qtmesh decimate already enforces).
3. **simplify-anim** (slice C4 / AnimationMerger::simplifyAnimation) —
   strip redundant animation keyframes under configurable tolerances
   (Balanced preset: 0.001 / 0.5° / 0.001). Operates on the first
   entity's skeleton + any animation-only skeletons MeshImporterExporter
   surfaced.

## Surface

CLI:
  qtmesh optimize <file> -o <output> [flags] [--json]

  Flags:
    --vertex-cache | --simplify-anim    Explicit per-stage toggles
    --all                               vertex-cache + simplify-anim
    --reduction <r>                     Drop fraction 0..0.95
    --target-tris N | --target-verts N  Target counts (mutually exclusive)
    --simplify-translation-tol T
    --simplify-rotation-deg-tol D
    --simplify-scale-tol S
    --json                              Structured report

  When no flag is passed, defaults to --vertex-cache --simplify-anim.
  When *only* a decimation knob is passed, the non-destructive defaults
  still run on top — "decimate this and clean it up" is what users mean.

MCP:
  optimize_mesh tool with the same shape (file/output + per-stage
  toggles + tolerances). Response carries per-stage applied/summary
  /details plus inputBytes/outputBytes/bytesDelta. Reuses Ogre's
  already-up Root in the editor process.

## Verification

Real-world result on `media/models/Rumba Dancing.fbx` with --reduction 0.5:
  6.3 MB → 1.4 MB  (77.7% smaller)
  ACMR 0.822 → 0.648
  10220 → 5048 triangles
  1156 / 2750 redundant keyframes removed (42.0%)

Same numbers via JSON --json output (verified shape).

## Docs

- CLAUDE.md: added optimize entries to the cheat sheet + CLIPipeline
  subcommand inventory + a new architecture section describing the
  pipeline + the Rumba result.
- website/src/DocsApp.jsx: new CmdSection with synopsis, options table,
  example output block, MCP API summary, and a callout linking each
  stage back to the slice that introduced it.
- src/CLIPipeline.cpp printUsage: new entry under Phase 6 commands.
- src/main.cpp: `optimize` added to the CLI-mode activation set so
  `qtmesh optimize ...` works without --cli.

## Slice F status

Phase 6 originally scoped a slice F for Draco glTF/glb compression.
Investigation revealed our installed Assimp was built without
ASSIMP_BUILD_DRACO; properly enabling Draco requires rebuilding Assimp
across Linux/macOS/Windows MinGW CI. Captured the work as issue #506
and proceeded to slice G — the optimize pipeline synthesizes
everything else we shipped this phase, so it's the natural place to
land before swinging back to the Draco rebuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(opt): address PR #507 review findings (8)

CodeRabbit + Codex flagged 8 distinct issues across the CLI and MCP
optimize paths. All real; all fixed in this commit.

## CLI (cmdOptimize)

1. Negative decimation targets now rejected at parse time. Before:
   `--reduction -1` parsed, set decimateRequested=true with
   reduction=-1.0, silently fell into the "target equals or exceeds
   current count; nothing to do" branch. Now: rejected with a clear
   error before any work starts. Same guard on --target-tris and
   --target-verts. (CodeRabbit major)

2. Decimation that fails to apply for a positive reduction now returns
   exit 1 with an error message, mirroring cmdDecimate. Before: the
   stage report showed applied=false but the command still exited 0 —
   automation could not tell a decimation failure from a no-op. The
   stage report is still emitted on the way out so callers can see
   the partial work. (Codex P1 / CodeRabbit major)

3. simplify-anim now walks every skeleton in the loaded scene, not
   just the first one found. Multi-entity assets (a co-loaded
   animation-only skeleton, or multiple skinned entities sharing one
   file) used to leave every skeleton after the first untouched.
   De-duplication by skeleton-name keeps cross-entity shared rigs
   from being simplified twice. Summary reports the skeleton count.
   (CodeRabbit major)

4. Global qtmesh --help now lists the simplify tolerance flags
   (--simplify-translation-tol / --simplify-rotation-deg-tol /
   --simplify-scale-tol). They were buried in the cmdOptimize usage
   error block; users running `qtmesh --help | grep -i tol` couldn't
   find them. (CodeRabbit minor)

## MCP (toolOptimizeMesh)

5. Scene isolation. The MCP server runs inside the editor process, so
   `MeshImporterExporter::importer(...)` was appending the optimize
   target into the user's live scene. Before stage 1 ran, the
   entities list contained both the just-imported asset AND every
   mesh the user already had loaded — and the optimizer happily
   mutated all of them. Now snapshot the entity-pointer set before
   the import, subtract after, and operate only on the delta. A
   trailing RAII cleanup destroys those scene nodes via
   Manager::destroySceneNode on every return path so the user's
   scene returns to exactly the state it was in before — no leaked
   nodes on success, on error, or on a thrown exception. (CodeRabbit
   critical)

6. Wrap the entire stage pipeline in a try/catch boundary catching
   Ogre::Exception, std::exception, and `...`. Before, a throw from
   any of vertex-cache / decimate / simplify-anim / export
   propagated up through Qt's signal dispatcher and crashed the
   editor process. The importer call was already guarded; the broader
   boundary now covers the rest. (CodeRabbit major)

7. Same decimation-not-applied → error treatment as the CLI: return
   makeErrorResult instead of silent applied=false in the stage
   report. (Codex P1)

8. Multi-skeleton simplify (matches CLI fix #3 above).

9. file.import Sentry breadcrumb on the source load — symmetric with
   the file.export breadcrumb already present. (CodeRabbit major
   refactor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(anim): flip the simplify default from Balanced to Conservative

User feedback: \"the animation got a bit trembling, probably because of
the optmization\" after running `qtmesh optimize` with default flags on
a Mixamo character clip. Balanced is right at the visual-perception
boundary for 30 FPS Mixamo data — barely-perceptible jitter on subtle
motion.

Simplify is destructive (rewrites the asset's animation tracks in
place), so the safe default should be Conservative — near-lossless,
~3-5× key reduction. Users who want Balanced or Aggressive's heavier
reduction now have to opt in by name. This matches the pattern used
elsewhere (--fix --dry-run, decimation requires explicit target, etc.).

## Where the default lives

`AnimationMerger::SimplifyTolerances{}` is the single source of truth.
Every surface (CLI `anim --simplify`, the Inspector "Simplify" button,
`scan --fix redundant_keyframes_pct`, slice G `qtmesh optimize`, MCP
`simplify_animation` + `optimize_mesh`) flows through that ctor or
through `tolerancesForPreset` which now also returns Conservative for
the empty / unknown preset case.

## What changed

- src/AnimationMerger.h: struct ctor defaults flipped to
  1e-4f / 0.05f / 1e-4f (Conservative). Updated the inline comment so
  future readers see the new contract.
- src/AnimationMerger.cpp `tolerancesForPreset`: the empty-preset and
  unknown-preset branches now return Conservative. The "balanced"
  branch sets the old defaults explicitly since they no longer match
  the ctor.
- src/AnimationMerger_test.cpp: pin the new default (empty + garbage
  preset → Conservative). The explicit "balanced" / "aggressive" cases
  still test their respective values.
- src/PropertiesPanelController.h: `analyzeAnimationKeyframes` and
  `simplifyAnimation` Q_INVOKABLE defaults flipped from "balanced" to
  "conservative" so QML callers that omit the preset arg get the safe
  choice.
- qml/PropertiesPanel.qml: the per-entity Simplify preset dropdown now
  initializes to "Conservative" (currentIndex 0) instead of "Balanced".
- src/ScanConfig.h: `redundantKeyframesTranslationTol` / RotationDegTol
  / ScaleTol defaults flipped to the Conservative triple. Affects
  `scan --fix redundant_keyframes_pct` when the user hasn't set the
  three `redundant_keyframes_*_tol` keys in qtmesh.yml.
- src/CLIPipeline.cpp: `OptimizeCmdArgs::animTranslationTol` etc match
  the new ctor defaults. `--help` text for `qtmesh anim --simplify`
  and `qtmesh optimize` updated to call out Conservative as the safe
  default and point users at Balanced / Aggressive for heavier
  reduction. The Conservative→Balanced→Aggressive ordering in the
  preset dropdown is also documented in the optimize block.
- src/MCPServer.cpp: both `simplify_animation` and `optimize_mesh`
  schema descriptions updated.
- website/src/DocsApp.jsx: the `qtmesh optimize` CmdSection options
  list shows the new default values (0.0001 / 0.05 / 0.0001) and
  notes why Conservative is the safe choice.

## Verification

`qtmesh optimize Rumba Dancing.fbx -o out.fbx` (no flags) now reports:

  [OK] simplify-anim: removed 216 / 2750 keyframes (7.9%) across 1 skeleton(s)

Down from 1156 / 2750 (42.0%) under Balanced. The 7.9% reduction is
near-lossless on Mixamo character clips — fixes the trembling without
giving up the bulk of the file-size win (6310 KB → 1437 KB, still
77.2% smaller because FBX export itself is the dominant compression).

Users who want the previous behaviour pass `--preset balanced` (for
`qtmesh anim --simplify`) or the explicit tolerance triple
(`--simplify-translation-tol 0.001 --simplify-rotation-deg-tol 0.5
--simplify-scale-tol 0.001`) on `qtmesh optimize`. The Inspector
"Simplify tolerance" dropdown still lists all three options, just
defaulting to Conservative now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(fbx): preserve normal map through FBX round-trip (issue #508)

User-observed bug confirmed via slice G's optimize pipeline verification:
running \`qtmesh optimize\` (and even plain \`qtmesh convert\`) on a Mixamo
FBX with a normal map produces an output FBX that re-imports without
the normal map. Root cause: MaterialProcessor::applyRTSSNormalMap
modifies a MaterialPtr at import time, but at export time
\`sub->getMaterial()\` can return a different instance of the same name
(common when a sidecar .material script and an FBX both register the
material) — the RTSS-created normal_map TUS disappears, and the
exporter writes only the diffuse texture reference.

## Fix

Two-sided fix on the import → export contract:

- **MaterialProcessor::applyRTSSNormalMap** now stashes the normal-map
  texture name on the material's first pass via UserObjectBindings
  (\`qtme.normal_map = texName\`). This survives the resource-group
  disagreement because it's keyed on the material instance the
  importer actually configured — and Ogre re-applies UOBs on every
  load.

- **FBXExporter::writeTextureObjects** + **the Texture→Material
  connection loop** both consult this UOB after walking the
  CONTENT_NAMED TUS list. When present, the recorded texture name is
  added to the texture-name set (so the Texture / Video FBX nodes
  emit it) and a \"NormalMap\" connection is created from the texture
  to the material. Result: the normal map round-trips even when the
  RTSS-created TUS isn't visible to the exporter's pass walk.

## Verification

\`qtmesh optimize ~/Downloads/Rumba\\ Dancing.fbx -o out.fbx\` (Mixamo
source with embedded normal map):

  Before:
    \$ qtmesh info out.fbx --verbose | grep -i normal
    [no output — normal map dropped]

  After:
    \$ qtmesh info out.fbx --verbose | grep -i normal
    Texture 'Boss_normal.png': Loading 1 faces(PF_B8G8R8,1024x1024x1) ...
    applyNormalMapsToEntity: built tangents for 'rumba_optimized'

The diffuse + normal both reach the rendered material on re-import.
\`qtmesh convert\` (no optimize stages) gets the same fix — this is a
generic FBX-export round-trip fix that the optimize pipeline just
happened to surface.

## Scope notes

- Issue #508 originally proposed walking RTSS render-state from
  FBXExporter to discover bound textures. Tried that first; the
  RTSS-created TUS evaporates by export time when materials cross
  resource-group boundaries, so render-state inspection finds the
  same empty list. UOB-on-the-pass survives the indirection because
  it's data on the *exact* Material instance MaterialProcessor
  modified, regardless of which group ends up serving it.
- MaterialEditorQML's normal-map slot changes and EditModeController's
  re-apply path also call \`RTShaderHelper::applyNormalMap\` directly.
  They aren't routed through this UOB path yet — a future fix can
  unify them via a shared helper that records the hint at every
  entry point.
- Issue #510 (\`qtmesh info\` should surface RTSS-bound normal map
  textures in its report) is still a separate concern. That ticket
  is about \*reading\*; this PR fixes \*writing\*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(opt): address PR #507 review findings on commit 256b50d

CodeRabbit re-reviewed the normal-map fix commit. 3 of 4 findings real:

1. PropertiesPanelController::simplifyAnimation still defaulted to
   "balanced". The companion analyzeAnimationKeyframes was flipped to
   "conservative" in ecd90af; this Q_INVOKABLE was missed. Any QML caller
   that omits the preset arg could now silently simplify more
   aggressively than intended. Flipped to "conservative" to match.

2. FBXExporter::writeDefinitions() counts Texture/Video objects from
   pass TUSes only, but writeTextureObjects() emits one extra Texture
   per pass that has a qtme.normal_map UOB hint (the issue #508 fix).
   For materials where RTSS dropped the normal-map TUS at runtime, the
   ObjectType Count is now one short of the actually-emitted Texture/
   Video pair → downstream FBX parsers reading the metadata see a
   mismatch. Mirror the UOB-fallback collection from writeTextureObjects
   into writeDefinitions so the counts stay in sync.

3. AnimationMergerStandaloneTest.TolerancesForPresetMapping only
   asserted .translation on the conservative fallback. A regression in
   .rotationDeg or .scale would slip through. Added the missing tuple
   asserts (0.05f and 1e-4f) for both the empty-string and unknown-
   preset paths.

Skipped (#2 from CodeRabbit): asked to catch Ogre::Exception instead of
std::bad_cast around Ogre::any_cast. Verified OgreAny.h in 14.5 — it
throws std::bad_cast, not Ogre::Exception. Current code is correct.

Verified locally: re-optimized Rumba Dancing.fbx (10220 → 5048 tris,
6310 KB → 2372 KB, 62.4% saved); qtmesh info on the output still
reports `Boss_normal.png` loaded and `applyNormalMapsToEntity: built
tangents`. Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fernandotonon added a commit that referenced this pull request May 20, 2026
The regression you saw on the website demo (shattered triangles, no
diffuse — screenshots #2 and #3) traces back to PR #640's switch to
UV2-based bake addressing. Symptom and root cause:

  - The OpenVAT bake walks Ogre's vertex-buffer in submesh-index
    order and writes texture column `c` for the c-th Ogre vertex.
  - The consumer's UV2 is supposed to carry that same column index
    so the shader's `texelFetch(pos_tex, ivec2(col, frame), 0)`
    reads the right vertex.
  - The original UV2 setup ran INSIDE Godot at runtime and assumed
    that Godot's loaded vertex order matches the file's vertex
    order — i.e. that `mesh.surface_get_arrays(i)[ARRAY_VERTEX][j]`
    is whatever Ogre wrote at column `running_offset + j`.

That assumption is false. Godot's resource importer reorders the
imported mesh's vertex buffer (cache locality + vertex compression).
The same broken assumption sits inside Unity's and Unreal's import
pipelines too — anywhere we shipped a bake against a separately-
imported source mesh, the column index → vertex index relationship
was silently broken at engine-import time.

This commit fixes the demo (and unblocks the same pattern for the
Unity / Unreal shader templates) by shipping a small per-vertex
**bind sidecar** that the consumer uses to recompute UV2 against
its own post-import vertex order.

Pipeline:

  C++ (qtmesh vat):
    Emit `<basename>_ogre_bind.bin` alongside the texture. Layout:
      uint32 magic = 0x42565442 ("BTVB"), uint32 version = 1,
      uint32 vertexCount, uint32 flags (pos | normal | uv),
      then per-vertex: 3 floats position, 3 floats normal,
                       2 floats UV0.
    Total cost on Rumba Dancing: 186 KB for 5828 verts.

  GDScript (VATInstance):
    On _ready, parse the sidecar. Build a (pos, normal, UV0) →
    ogre_index lookup. For each Godot vertex, look up its sidecar
    column by quantized-signature match (1e-5 for positions, 1e-2
    for normals after observing 5e-3 drift from Godot's re-
    normalisation, 1e-3 for UVs). Undo Godot's glTF V-flip on the
    consumer side before matching. Write the resulting Ogre column
    index as UV2. Ambiguous buckets (verts that share all three
    fields — degenerate weight splits) fall back to identity and
    surface a count via push_warning.

  Godot export preset:
    include_filter="*.bin" so the sidecar survives `--export-release`
    (default `all_resources` filter doesn't ship non-resource files).

End-to-end on Rumba Dancing: 5791/5828 (99.4%) of Godot vertices
get the exact Ogre column they should. 37 verts (0.6%) hit the
ambiguity sentinel and fall back to identity — these are degenerate
weight-only splits where the signature can't disambiguate. They'd
visually overlap regardless of which side we pick.

Also bumps the bundled Godot web export + the demo `source.gltf` /
`_pos.png` / `_ogre_bind.bin` so the live website picks up the fix
on next deploy.
fernandotonon added a commit that referenced this pull request May 20, 2026
…acts (#646)

* fix(export): glTF/GLB skip aiProcess_ConvertToLeftHanded

The main Assimp exporter path applied ConvertToLeftHanded for every
format except DirectX (.x). glTF is right-handed like Ogre, so the
conversion negates Z and reverses triangle winding on every exported
.gltf / .glb.

This is silent for typical round-trips (an importer just sees the
mirrored mesh and doesn't care) but breaks any consumer that pairs
the glTF with a separately-computed vertex stream — concretely the
OpenVAT bake. `qtmesh vat` emits:

  - <anim>_pos.png   (positions read from Ogre's RH vertex buffer;
                      columns indexed by Ogre's vertex order)
  - source.gltf      (Assimp export; pre-fix had Z flipped)

The two were in different handedness spaces, so VAT shaders that
texelFetch positions by vertex index displaced every vertex against
a Z-mirrored bind pose. From the diff of two source.gltf files
baked before/after this change:

  v0  before:  x=-0.01144  y=+1.28867  z=+0.22631
  v0  after:   x=-0.01144  y=+1.28867  z=-0.22631
  texel v0:    x=+0.01476  y=+1.26171  z=-0.22101   <-- matches "after"

The pose exporter (`exportCurrentPose`) already has the correct
skip list ("x" || "gltf2" || "glb2"); this brings the main
`exporter()` path into alignment.

Demo asset re-bake is intentionally NOT included here: the current
Assimp export drops material/image references in fresh bakes (a
separate regression worth tracking on its own). Shipping just the
C++ fix avoids dragging that into this PR; the bundled
tools/godot-vat-demo/assets/Rumba/source.gltf will be regenerated
once the texture-binding regression is also resolved.

* fix(vat): three independent bugs that broke OpenVAT consumers

The website demo and any other OpenVAT consumer rendering meshes baked
by qtmesh vat hit three distinct alignment bugs. This commit lands them
together because they only become visible in combination, and isolating
any one made the other two appear "fixed" by accident.

1. ConvertToLeftHanded on glTF/GLB export (handedness)

   `MeshImporterExporter::exporter` applied aiProcess_ConvertToLeftHanded
   for every format except DirectX (.x). glTF/GLB are right-handed like
   Ogre, so the conversion negates Z and reverses triangle winding on
   every exported file.

   The pose exporter (`exportCurrentPose`) already had the correct skip
   list ("x" || "gltf2" || "glb2"); this brings the main exporter into
   alignment. Without the fix, source.gltf was Z-mirrored relative to
   the position texture the bake wrote — consumers fetched a vertex
   from the wrong half of the silhouette and the model "looked rotated".

2. Vertex-column permutation (defensive remap)

   Assimp's gltf2 exporter hardcodes
   `aiProcess_JoinIdenticalVertices | aiProcess_SortByPType` as
   mandatory pre-processing (assimp/code/Common/Exporter.cpp:186) —
   no flag from our calling code can disable them. For meshes with
   non-unique skinning weights JoinIdenticalVertices does merge or
   reorder vertices within a primitive, which would silently break
   the bake's column-index → vertex-index relationship.

   `cmdVat` now exports the glTF, reads its position buffer back, and
   builds a per-vertex permutation by matching positions against
   Ogre's bind-pose buffer. The permutation is passed via
   `VATBaker::Options::vertexPermutation` and applied at PNG-encode
   time so each row's columns land in the glTF's vertex order.

   For meshes where Assimp's join is a no-op (this is the common case
   — combinations 1+3 mask 2 in current asset coverage) the
   permutation is identity and costs nothing.

3. Texture references dropped on glTF export (auto-numeric TUS names)

   When `qtmesh vat` re-runs against an asset that already has a
   `.material` sidecar from a prior export, Ogre auto-parses that
   .material on `initialiseResourceGroup`. The script's
   `texture_unit { texture ... }` blocks (no explicit name in the
   block header) get Ogre's auto-generated names "0", "1", ...
   `MaterialProcessor` then sees the material as "already exists"
   and takes the additive existing-material path that does not
   rewrite TUS names back to "diffuse_map".

   When `buildAiMaterialFromOgre` then walked those TUS for the
   glTF export, "0" matched none of the canonical slot names and
   fell through to `aiTextureType_UNKNOWN` — which Assimp's gltf2
   exporter does not surface as a `baseColorTexture`. End result:
   meshes rendered without diffuse on every re-bake.

   Fix: treat all-digits TUS names (Ogre's auto-numeric default)
   the same as empty and "diffuse_map" — route as DIFFUSE.

Also re-runs the bundled demo bake + Godot web re-export against
the fixed binary so `website/public/demo/index.html` reflects all
three fixes on the next deploy.

* review(vat): match on full vertex signature + validate permutation

Addresses three CodeRabbit review notes on PR #646:

1) **Critical / VATBaker** — validate `vertexPermutation` is a full
   bijection over `[0, vertexCount)` before passing to `packOpenVAT16`.
   Length-only check let through entries that walked past the PNG row
   stride or duplicated columns, silently corrupting the bake.

2) **Major / CLIPipeline** — only advertise `source.gltf` in the bake
   report when alignment actually succeeded. Previously a degraded
   bake (failed glTF read-back, count mismatch, or ambiguous
   permutation) silently fell back to identity packing while the
   report still claimed "vertex order matches the bake" — handing
   consumers a mesh that didn't match the texture columns. Now gated
   behind a `sourceMeshMatchesBake` flag set only on a successful
   permutation match, with explicit warnings on every fallback path.

3) **Major / CLIPipeline** — extend the bake↔glTF match key from
   position-only to (position, normal, UV0). On UV seams, hard edges,
   and weight-splits, multiple distinct vertices share the same
   bind-pose position; matching by position alone would FIFO-resolve
   them arbitrarily and misroute normals between them, producing
   subtle shading glitches.

   Two-tier quantization: positions at 1e-5 (sub-mm on a 1-unit
   model, exact through float32 round-trip), normals + UVs at 1e-3
   (Assimp re-normalises normals on glTF export — empirically the
   normal magnitudes drift by up to ~1e-5 per component, which a
   1e-5 quantizer split into separate buckets). UVs are V-flipped
   on the glTF side to undo Assimp's implicit `aiProcess_FlipUVs`.

   Ambiguous buckets (two glTF vertices with identical quantized
   signature) now refuse the permutation and fall back to identity
   packing rather than guessing — same outcome as a missing match.

Tested against Rumba Dancing.fbx: full 5828-vertex bind-pose
signature matches the post-export glTF exactly, permutation is
identity for this asset (Assimp's JoinIdenticalVertices was a
no-op), `mesh: ... (vertex order matches the bake)` correctly
advertised.

* fix(vat-demo): bind-pose sidecar so consumers survive importer reorder

The regression you saw on the website demo (shattered triangles, no
diffuse — screenshots #2 and #3) traces back to PR #640's switch to
UV2-based bake addressing. Symptom and root cause:

  - The OpenVAT bake walks Ogre's vertex-buffer in submesh-index
    order and writes texture column `c` for the c-th Ogre vertex.
  - The consumer's UV2 is supposed to carry that same column index
    so the shader's `texelFetch(pos_tex, ivec2(col, frame), 0)`
    reads the right vertex.
  - The original UV2 setup ran INSIDE Godot at runtime and assumed
    that Godot's loaded vertex order matches the file's vertex
    order — i.e. that `mesh.surface_get_arrays(i)[ARRAY_VERTEX][j]`
    is whatever Ogre wrote at column `running_offset + j`.

That assumption is false. Godot's resource importer reorders the
imported mesh's vertex buffer (cache locality + vertex compression).
The same broken assumption sits inside Unity's and Unreal's import
pipelines too — anywhere we shipped a bake against a separately-
imported source mesh, the column index → vertex index relationship
was silently broken at engine-import time.

This commit fixes the demo (and unblocks the same pattern for the
Unity / Unreal shader templates) by shipping a small per-vertex
**bind sidecar** that the consumer uses to recompute UV2 against
its own post-import vertex order.

Pipeline:

  C++ (qtmesh vat):
    Emit `<basename>_ogre_bind.bin` alongside the texture. Layout:
      uint32 magic = 0x42565442 ("BTVB"), uint32 version = 1,
      uint32 vertexCount, uint32 flags (pos | normal | uv),
      then per-vertex: 3 floats position, 3 floats normal,
                       2 floats UV0.
    Total cost on Rumba Dancing: 186 KB for 5828 verts.

  GDScript (VATInstance):
    On _ready, parse the sidecar. Build a (pos, normal, UV0) →
    ogre_index lookup. For each Godot vertex, look up its sidecar
    column by quantized-signature match (1e-5 for positions, 1e-2
    for normals after observing 5e-3 drift from Godot's re-
    normalisation, 1e-3 for UVs). Undo Godot's glTF V-flip on the
    consumer side before matching. Write the resulting Ogre column
    index as UV2. Ambiguous buckets (verts that share all three
    fields — degenerate weight splits) fall back to identity and
    surface a count via push_warning.

  Godot export preset:
    include_filter="*.bin" so the sidecar survives `--export-release`
    (default `all_resources` filter doesn't ship non-resource files).

End-to-end on Rumba Dancing: 5791/5828 (99.4%) of Godot vertices
get the exact Ogre column they should. 37 verts (0.6%) hit the
ambiguity sentinel and fall back to identity — these are degenerate
weight-only splits where the signature can't disambiguate. They'd
visually overlap regardless of which side we pick.

Also bumps the bundled Godot web export + the demo `source.gltf` /
`_pos.png` / `_ogre_bind.bin` so the live website picks up the fix
on next deploy.

* fix(vat-demo): tiebreak ambiguous matches with continuous distance

The signature-bucket approach left 37 verts on Rumba unmatched
because Godot's importer drift on normals (~5e-3 per component)
straddles the 1e-2 quantization grid for some verts — value
-0.324984 (Ogre) buckets to -32 while value -0.325031 (Godot,
post-renormalise) buckets to -33, even though they're the same
underlying normal. Loosening to a 5e-2 grid still missed 12 verts
because boundary cases just moved to the new grid.

Drop the quantize-then-bucket approach for normals + UVs. Instead:

  - Bucket by quantized POSITION only (1e-5, exact through float32)
  - Each bucket holds ALL candidate Ogre indices that share that
    position (degenerate splits — same point in space, different
    weights/normals/uvs)
  - At match time, pick the candidate whose actual normal and UV
    are closest by squared-distance — continuous comparison, no
    further quantization, no grid-boundary effects

On Rumba: 5828/5828 verts now match (was 5791/5828 = 99.4%). The
remaining 37 were the verts I was warning about in the previous
commit — they're now disambiguated correctly. Cost is still
O(N + matches) since the typical bucket holds 1 or 2 candidates
and the distance check is constant-work per candidate.

Re-runs the Godot web export so the website demo picks up the
fully-matched bake.

* fix(export): keep aiProcess_FlipUVs on glTF export

When I dropped aiProcess_ConvertToLeftHanded for glTF in the
earlier handedness fix, I also dropped the V-flip that was
bundled into it. Symptom: every glTF consumer (Godot, three.js,
Blender) V-flips on import per the glTF spec (V=0 at top), so
without a compensating flip on EXPORT the UVs end up upside-
down — face textures land on legs, shirt on hat, etc. The
website demo screenshot showed exactly this.

Bring back V-flip as a standalone aiProcess_FlipUVs flag for
glTF/GLB only. Unlike ConvertToLeftHanded, FlipUVs touches only
the UV V coordinate — it leaves positions, normals, and winding
in Ogre's RH convention. So the OpenVAT bake (which doesn't
depend on UVs at all — texture columns are vertex-index addressed)
is unaffected, and consumers that round-trip through the glTF +
their own importer's V-flip land in the right convention.

Also drops the 1-V flip from VATInstance.gd's matcher — the
sidecar V (Ogre native) and Godot's post-import V are now equal,
so no in-script flip is needed.

Re-runs the bundled demo bake + Godot web re-export so the
website picks up the texture fix on next deploy.

* fix(vat-demo): drop normal negation in shader

The shader was negating the decoded normal to compensate for a
historical inconsistency: aiProcess_ConvertToLeftHanded was
applied on the IMPORT side (FBX → Ogre) but not on the EXPORT
side (Ogre → glTF), so the bake's source mesh and the exported
glTF disagreed on the Z sign — and the negation papered over it.

After the recent exporter rework, both sides are in the same
RH frame: positions, normals, and winding all consistent. The
texture's normals are already in Godot's expected convention.
The negation now flips them WRONG and produces back-lit shading.

Re-runs the Godot web export so the website picks up the
correct lighting.
fernandotonon added a commit that referenced this pull request May 23, 2026
CI fix:
- Add src/MinimalEXRWriter.cpp to tests/CMakeLists.txt so the three
  MaterialEditorQML test binaries link against VATBaker::bake's
  MinimalEXR::writeRGB32F reference. Was only in src/CMakeLists.txt.

Code-review applied (still-valid items only; earlier comments about
UV2 baking failure / non-idempotent material build / success-banner
guards / spawn-failure aborts are already implemented in current code):

- #7  Missing T_OpenVAT_Pos/T_Boss_Diffuse now hard-stops the
       bootstrap. Previously only the mesh check aborted, so a failed
       texture import could still reach the success banner with an
       unbound `pos_tex` and produce a dancer frozen in bind pose.
- #8  verify_imported_uv_channels now returns bool, and main()
       aborts when it returns False (a SkeletalMesh import despite
       our static-mesh override). Stops the script from continuing
       with a known-bad import.
- #6  spawn_dancer_in_level: when get_all_level_actors() raises,
       return None instead of falling back to [] — the empty list
       skipped the cleanup pass and allowed reruns to stack
       duplicate OpenVAT_Dancer actors. main() already handles None
       via the partial-failure path.
- #3  Replaced the silent `except Exception: pass` on actor
       destruction with a log_warning so cleanup failures are
       diagnosable instead of swallowed.
- #9  init_unreal.py: replaced the hard-coded mesh path tuple with
       a recursive AssetRegistry sweep under /Game/Rumba/ matching
       any StaticMesh or SkeletalMesh. Keeps the "skip if mesh
       present" decision in sync with build_vat_demo's
       find_imported_mesh, so a non-canonically-named import
       doesn't trigger a rebuild every editor open.

Bumps OPENVAT_BUILD to 33.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fernandotonon added a commit that referenced this pull request May 24, 2026
* feat(vat): Unreal demo project — Python-driven asset bootstrap

Companion to tools/godot-vat-demo/ — gives Unreal users a one-import
path to play the Rumba bake without wiring materials by hand. The
folder is structured as a real .uproject the user opens in UE 5.3+;
running build_vat_demo.py from the Python console then creates the
Material, configures the position-texture import settings, and
scaffolds the demo Blueprint.

  tools/unreal-vat-demo/
  ├── QtMeshVAT.uproject              ← UE 5.3+ project descriptor
  ├── README.md                       ← step-by-step setup
  ├── .gitignore                      ← excludes Binaries/Saved/cooked .uassets
  └── Content/
      ├── Rumba/                      ← bake artifacts (same as Godot demo)
      │   ├── source.gltf + .bin      ← vertex-order-aligned mesh
      │   ├── Boss_diffuse.png
      │   ├── mixamo.com_pos.png      ← 16-bit position+normal texture
      │   ├── mixamo.com-remap_info.json
      │   ├── mixamo.com_ogre_bind.bin← per-vertex bind signature
      │   └── openvat.usf             ← Custom-node body
      └── Python/build_vat_demo.py    ← run once to bootstrap

Why a Python bootstrap instead of pre-built .uassets:
Unreal's .uasset is a proprietary binary that re-cooks per engine
version — committing pre-built ones would re-break on every UE
upgrade. All the data (bake + shader + sidecar) is text/binary
standard formats; only the engine-specific glue (Material,
Texture import settings, BP skeleton) is engine-version-dependent,
and that's exactly what build_vat_demo.py builds.

Limitations documented in README:
- UV2-bake step is engine-version-dependent (Geometry Script plugin
  in 5.4+, C++ helper for 5.3).
- The Material assumes the bind pose is at the actor origin (true
  for Mixamo; documented as a caveat for other rigs).
- No HierarchicalInstancedStaticMesh perf variant yet — the
  1000-instance MultiMesh equivalent ships in
  tools/godot-vat-demo/scripts/PerfSpawnerVAT.gd and can be
  ported as a follow-up.
- No Unreal-web demo possible (UE's HTML5 export was deprecated).

Website docs gain a pointer to the Unreal sample project next to
the existing Godot live-demo link.

* fix(unreal-demo): drop fake GLTFImporter plugin, use Interchange

The .uproject's `Plugins` block listed `GLTFImporter` as a required
plugin, which doesn't exist in stock UE 5.x — Unreal threw
"This project requires the 'GLTFImporter' plugin, which could not
be found" on first open. glTF import is provided by Unreal's
built-in `Interchange` framework (UE 5.0+); no marketplace plugin
needed.

  - QtMeshVAT.uproject: drop the GLTFImporter dependency. Only
    PythonScriptPlugin + EditorScriptingUtilities remain, both
    shipped with stock UE.
  - build_vat_demo.py: switch glTF import from the non-existent
    `unreal.GLTFImportFactory()` to `InterchangeManager`, with a
    graceful warning + manual-import fallback for older engines
    where the Python API isn't available.
  - README: document the manual-import fallback.

The bake artifacts, the Custom-node Material, and the BP scaffold
are unchanged.

* review(unreal-demo): bake_uv2 returns False, build_material idempotent

Addresses Codex P1 + P2 on PR #652.

P1 — bake_uv2 used to return True even though it only logs the
matching plan; it never actually writes UV2 back to the mesh.
Combined with the bootstrap printing "=== Bootstrap done. ===",
users could believe the demo was ready when in fact the shader's
`pos_tex.Load(int3(col, …))` reads from arbitrary texels and the
dancer renders as scattered triangles. Now:
  - bake_uv2 returns False until a real write step ships.
  - main() reads the return value and prints a loud "Bootstrap
    INCOMPLETE" banner pointing at README Step 4 when False.
  - The warning inside bake_uv2 was upgraded to log_warning so
    it stands out in the Output Log.

P2 — build_material wasn't idempotent. create_asset on an existing
path returns None, the function then aborted with "Failed to create
M_OpenVAT", and a user rerunning after changing bake inputs (new
fps, new bounds, new texture) ended up with a stale material and
no graceful error. Now: detect the pre-existing asset, delete it,
recreate from scratch so the graph reflects the latest sidecar.
Idempotent at the granularity the bootstrap docs promise.

* feat(unreal-demo): drop UV2-write step, rely on --emit-uv2 in source.gltf

After PR #654 added `qtmesh vat --emit-uv2`, the bake's source.gltf
carries the per-vertex column index as TEXCOORD_1 directly. Unreal's
mesh importer reorders vertices for cache locality, but a vertex
attribute travels with its vertex through any reorder — so the
imported mesh's TexCoord[1] already points at the right column in
the position texture. No runtime UV2-baking, no bind-sidecar
matcher, no engine-version-specific Geometry Script paths.

This turns the previously-incomplete Unreal demo into a true
"open and play" setup:

  1. Open QtMeshVAT.uproject
  2. Run build_vat_demo.py from the Python console
  3. Wire BP_VATDancer's 4-node Tick (documented in README)
  4. Drop the actor into a level, hit Play.

Code changes:

  - Re-baked Content/Rumba/source.gltf + source.bin with
    `qtmesh vat --emit-uv2`. The .gltf now carries TEXCOORD_1 on
    every primitive; size grew from 642 KB to 689 KB for the UV2
    payload.
  - Replaced bake_uv2() (which returned False because it couldn't
    portably commit the UV2 write) with verify_gltf_has_uv2() —
    a fast pre-check that fails the bootstrap loudly if a user's
    bake folder predates --emit-uv2.
  - Dropped the obsolete read_ogre_bind() + matching plan and the
    struct/Geometry-Script path references.
  - Material graph comment updated: TexCoord[1] now comes straight
    from glTF's TEXCOORD_1, not a post-import EUW write.
  - README: drop "Step 4: bake UV2" entirely. New error case for
    "source.gltf is MISSING TEXCOORD_1" points the user at
    `qtmesh vat --emit-uv2`.

* chore(unreal-demo): drop accidentally-committed Config/

DefaultEngine.ini and DefaultInput.ini are auto-generated by UE
on first project open and contain a randomly-generated SecurityToken
that has no business being in version control. Slipped in with the
previous commit because they were untracked when I staged the
whole tools/unreal-vat-demo/ folder.

Removed from the index and added Config/ to the project's .gitignore
so subsequent project opens don't re-stage them.

* feat(unreal-demo): auto-spawn dancer + self-driving Time-based material

The previous bootstrap left the user with an empty map: it created
the Material + an empty BP_VATDancer (Python can't wire BP graphs)
and asked the user to drop the actor into a level + write a 4-node
Tick. Most users — me included on first try — just saw an empty
scene and assumed it was broken.

Two changes drop that gap:

1. Material self-drives current_frame from `Time × fps` instead of
   reading a scalar parameter the actor has to poke each Tick.
   Replaces the `current_frame` Scalar Parameter with an
   ExpressionTime → Multiply chain, plus a new `fps` Scalar
   Parameter (default 30). No Blueprint, no Tick, no
   MaterialInstanceDynamic required — Unreal's Time node ticks in
   the editor too, so the animation plays without hitting Play.

2. spawn_dancer_in_level() drops a SkeletalMeshActor at the world
   origin with SK_Rumba + M_OpenVAT applied and Animation Mode set
   to None. Replaces the BP_VATDancer scaffold + the manual
   4-node-wiring instructions in the README.

   Looks up `EditorLevelLibrary` (UE 5.0..5.4) or the new
   `EditorActorSubsystem` (UE 5.5+), and tries both names for the
   skeletal-mesh property setter — set_skeletal_mesh_asset is the
   5.4+ name; the editor_property fallback covers older builds.
   Idempotent — re-runs delete the previous OpenVAT_Dancer first.

The bootstrap now ends with the dancer visibly animating in the
editor viewport. README simplified accordingly: drop the entire
"Wire BP_VATDancer's tick" step.

* fix(unreal-demo): diagnostic logging + spawn 200 cm in front of camera

Re-running the bootstrap left the user staring at an empty viewport
with no clue where it failed. Every step now logs what it loaded,
the spawn function reports each branch, and a step-by-step trace in
main() makes the failing stage obvious in the Output Log.

Concretely:
  - main() now logs "step 1/5", "step 2/5", … and exits with a
    "Bootstrap STOPPED at step N" banner on any prerequisite miss.
  - Right after import_bake_assets() we `unreal.load_asset` each
    expected asset (SK_Rumba, T_OpenVAT_Pos, T_Boss_Diffuse) and
    log the resulting object. Silent import failure is now visible.
  - spawn_dancer_in_level() spawns at (200, 0, 0) facing -X (Mixamo
    bind pose) instead of at the origin — the default empty map's
    editor camera looks at +X from ~(0,0,200), so (200,0,0) lands
    front-and-center. The actor is also selected after spawn and
    the viewport is invalidated so the dancer shows immediately.
  - Tries set_skeletal_mesh_asset / set_skeletal_mesh /
    skeletal_mesh_asset / skeletal_mesh in turn (the property name
    moved across 5.3..5.5) and logs an error if none succeeded.
  - get_all_level_actors() failures are caught + logged rather than
    silently breaking the cleanup pass.

With this, "I see an empty map" reduces to a single log line that
says which step gave up.

* feat(unreal-demo): init_unreal.py auto-runs bootstrap on project open

Most users open a .uproject by double-clicking it; nobody actually
reads the README's "open Output Log, switch to Python, type
`py Content/Python/build_vat_demo.py`" step. So the user gets the
editor open, sees an empty viewport, and concludes the demo is
broken.

UE's PythonScriptPlugin auto-discovers `init_unreal.py` on any of
its startup-script paths — `<project>/Content/Python/` is on that
path by default. Drop the autorun there and the bootstrap fires on
the editor's first tick, no UI interaction needed.

  init_unreal.py:
    - Pre-checks: bake artifacts present AND M_OpenVAT doesn't
      already exist. Re-opens of a previously-bootstrapped project
      no-op (so we don't wipe + rebuild every launch).
    - Defers the actual main() call until the next editor tick via
      register_slate_post_tick_callback. init_unreal.py fires very
      early — before the level loader has finalised — and
      spawn_actor_from_class returns None at that point.
    - One-shot: unregisters its callback after the first fire so it
      doesn't run on every tick.
    - Fallback: if the slate-tick API isn't available (older UE
      builds), runs immediately and accepts the small risk that the
      spawn fails with a clear error.

  README:
    - Top-of-flow is now "double-click .uproject → bootstrap fires
      on its own". Three steps instead of five.
    - "How to re-run / how to force a rebuild" pushed below the
      golden path so users who just want to see the dancer don't
      have to read about it.
    - Troubleshooting section keeps the manual-import + missing-
      TEXCOORD_1 escape hatches.

* fix(unreal-demo): locate skeletal mesh wherever Interchange put it

Interchange's `destination_name` parameter on `ImportAssetParameters`
turns out to be advisory for glTF — UE 5.5/5.7 always writes the
skeletal mesh under `/Game/<src>/source/SkeletalMeshes/<mesh-name>`
regardless of what we asked for. The script was looking at
`/Game/Rumba/SK_Rumba`, finding nothing, and stopping with
"SK_Rumba did not import" while the mesh was actually sitting at
`/Game/Rumba/source/SkeletalMeshes/SK_Rumba`.

New `find_skeletal_mesh()` tries:
  1. Known paths in order:
       /Game/Rumba/SK_Rumba
       /Game/Rumba/source/SkeletalMeshes/SK_Rumba
       /Game/Rumba/Rumba_Dancing_mesh
       /Game/Rumba/source/SkeletalMeshes/Rumba_Dancing_mesh
  2. AssetRegistry sweep under /Game/Rumba/ for the first
     SkeletalMesh asset.

Reported by the user's UE 5.7 init_unreal log:
  LogInterchangeImport: Warning: Node [Rumba_Dancing_mesh] with a
    skinned mesh is not root.
  LogPython:   SK_Rumba = None
  LogPython: Error: === Bootstrap STOPPED: SK_Rumba did not import.

* fix(unreal-demo): WPO delta + glTF→Unreal swizzle so the dancer animates

Two compounding bugs caused the dancer to render as a static
upside-down bind pose:

1. The Custom node wrote ABSOLUTE model-space coordinates into
   WPO. WPO expects an OFFSET, not a target position. The dancer's
   vertices were being pushed by ~1 cm (because the bake's bounds
   are ±1 m and WPO is interpreted in cm) — effectively static.

2. The bake's texels + bounds live in glTF-native space (Y-up RH,
   meters). Unreal's Interchange importer has already swizzled the
   imported SkeletalMesh to Z-up LH cm. So even a working offset
   would land on the wrong axis — the visible bind pose was
   upside-down because the offset's Y/Z were flipped.

Fix: read the bind position via the new MaterialExpressionPreSkinnedPosition
node (object-space, Z-up, cm), then compute the WPO offset inside
the Custom node as:

  target_yup_m  = bounds_min + p * (bounds_max - bounds_min)
  target_zup_cm = (target.x, -target.z, target.y) * 100   // Y-up → Z-up + m→cm
  wpo           = target_zup_cm - bind_local              // delta in cm, Z-up

That puts the swizzle and the unit conversion next to each other in
HLSL where they're easy to reason about, and keeps the actor's
transform clean: the spawned OpenVAT_Dancer no longer needs the
manual 200-unit offset or the 180° rotation hack — it spawns at
(0,0,0) facing +X and the material does all the coordinate-system
work.

Material `Time × fps` continues to drive `current_frame` inside the
Custom node, so the animation loops forever (the HLSL fmod with
N=frame_count was already there; just nothing was actually being
emitted as an offset for WPO to apply).

* fix(unreal-demo): version-tag M_OpenVAT so stale builds auto-rebuild

The auto-runner's "skip if material exists" check was preventing
the coordinate-system fix (0e7aa27) from running on user's next
project open. Replace it with a build-number compare: bump
OPENVAT_BUILD in build_vat_demo.py when the graph layout changes
and init_unreal will rebuild on next open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): import glTF as StaticMesh to preserve TEXCOORD_1

UE's SkeletalMesh importer renormalises every render section's
secondary UVs to its own [0,1] range, which destroys the absolute
column index `qtmesh vat --emit-uv2` writes — submeshes whose
column 0 happens to land on a stationary vertex render frozen,
others read the wrong slice of the position texture and produce
chaotic triangles.

Force Interchange to route the glTF as a StaticMesh (skipping the
skeleton path entirely; the VAT replaces skinning anyway) and use
MaterialExpressionLocalPosition instead of PreSkinnedPosition for
the bind-pose source. Also pre-clean any leftover SkeletalMesh
imports on each bootstrap run and spawn StaticMeshActor when the
static path took. Bumps OPENVAT_BUILD to 4 so auto-runner picks up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): set force_all_mesh_as_type on the right sub-object

The previous attempt set Interchange's ForceAllMeshAsType on the
mesh_pipeline sub-object, but on UE 5.7 the property actually lives
on common_meshes_properties — so the override silently no-op'd and
the glTF kept routing through the skeletal-mesh path. Verified
against the engine headers
(InterchangeGenericAssetsPipelineSharedSettings.h:69-70).

Also pass the transient pipeline via params.pipelines (5.7+ accepts
that directly) instead of override_pipelines + SoftObjectPath, which
needs an on-disk asset. Plus: init_unreal now rebuilds when the mesh
is missing from disk, not just the material — otherwise a failed
import locks the demo into "skip; material is current."

Bumps OPENVAT_BUILD to 5 so the auto-runner picks up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): LocalPosition.IncludedOffsets=ExcludeOffsets to break WPO feedback loop

MaterialExpressionLocalPosition defaults to IncludeOffsets, meaning
it returns the post-WPO vertex position — but the WPO is *what we're
computing*. Result: WPO = target - (bind + WPO_prev), a fixed-point
iteration that converges to bind pose for most vertices and
oscillates for some, which is exactly the "static head, weird foot
rotation, twisted eyes/mouth" symptom in the screenshot.

ExcludeOffsets returns the pre-WPO (= bind) vertex, which is what
the math actually requires.

Bumps OPENVAT_BUILD to 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): apply M_OpenVAT to every material slot

The Rumba mesh has 11 primitives → 11 material slots. spawn_dancer
was only calling set_material(0), so submeshes 1..10 kept the
Interchange-imported PBR materials (Skin_MAT, Clothes_MAT, etc.)
with no WPO and rendered in bind pose. The user's screenshot
showed exactly this: vest animating but head/eyes/hat/scarves
frozen in the bind pose.

Loop every slot, plus enable bIgnorePause on the Time node as a
safety net for editor-only viewports. Bumps OPENVAT_BUILD to 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): force Opaque blend so depth-write/test produces correct z-order

MaterialFactoryNew's default blend mode varies by UE version + project
settings; 5.7 can default new materials to Masked/Translucent which
disables depth writes and causes the "no z-index" look (body parts
visually overlapping in random draw order). Explicitly set
Opaque/Surface/DefaultLit/single-sided so the mesh depth-tests
correctly. Bumps OPENVAT_BUILD to 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): keep sections separate so TEXCOORD_1 column index survives

Interchange merges glTF primitives that share a material name into
one render section (Mixamo's Skin_MAT covers head + arms + feet:
3 primitives → 1 section). The merged section renumbers TEXCOORD_1
within its combined vertex buffer, so the column index points at
the wrong texture column and head verts read body data and vice
versa. Visually: submeshes appear to render through each other.

Set b_keep_sections_separate=True on common_meshes_properties so
each glTF primitive becomes its own section with its original
TEXCOORD_1 values intact. Bumps OPENVAT_BUILD to 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): try snake-name variants for keep_sections_separate + wipe SM_Rumba between runs

The previous attempt used `b_keep_sections_separate` (the auto
snake-case form) but UE 5.7's Python binding rejected it. Try the
b-less `keep_sections_separate` first, then the b-prefixed variants;
log whichever takes.

Also: the pre-import cleanup only deleted SkeletalMesh-flavoured
assets, so a leftover SM_Rumba.uasset from build 8 was being reused
(carrying the merged-section config) instead of re-imported with the
new override flags. Nuke every mesh-side asset under /Game/Rumba/
each run. Textures still skip the cleanup — they're idempotent.

Bumps OPENVAT_BUILD to 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): compute WPO as delta from bake frame 0 (drop bind_local dependency)

Drop the LocalPosition dependency entirely — instead of
WPO = target - bind_local, sample the bake at frame 0 (Mixamo's
bind/T-pose by construction) and compute WPO = (target - p0)
swizzled into Unreal cm. Frame 0 and target come from the same
texture in the same coordinate system, so the subtraction sidesteps
every "what coord system does LocalPosition return on UE 5.7
for a static mesh?" question.

Also dump per-section material-slot index after import so we can
diagnose Interchange's material-merge behaviour from the log.

Bumps OPENVAT_BUILD to 11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): match Interchange's (x, z, y) swizzle exactly (no Y negation)

Interchange's glTF importer uses (X, Y, Z) → (X, Z, Y) without
negating Y — handedness flip is handled via reversed triangle
winding instead (verified in UE 5.7's ConversionUtilities.h:20 and
GLTFMeshFactory.cpp:632). My shader was using the standard
(x, -z, y) swizzle, which flipped one axis vs the imported mesh —
hence the dancer moving in unexpected directions while remaining
coherent.

Bumps OPENVAT_BUILD to 12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(unreal-demo): expose swizzle matrix as VectorParameters for in-editor tweaking

The bake's coord system depends on the entire import chain that
produced it (Ogre's Assimp uses ConvertToLeftHanded which negates
Z, Interchange uses (X, Z, Y) and reverses winding, etc.).
Hardcoding one swizzle doesn't work for every source. Drive the
final Y-up→Z-up mapping from three VectorParameters
(swizzle_row_x/y/z) so the user can twiddle in the Material editor
without rebuilding — change a single sign or permute rows and the
preview updates immediately.

Defaults to the (X, Z, Y) Interchange swizzle. To try alternatives:
  - (X, -Z, Y):   swizzle_row_y = (0, 0, -1), swizzle_row_z = (0, 1, 0)
  - (-X, -Z, Y):  swizzle_row_x = (-1, 0, 0), etc.

Bumps OPENVAT_BUILD to 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): subtract real bind from absolute target (not frame-0 delta)

Mixamo's Rumba animation frame 0 is the first frame of the dance,
mid-pose — NOT the T-pose bind. Treating frame 0 as bind meant the
bake delta was "motion from mid-frame-0 pose," then applied on top
of the T-pose LocalPosition — produced coherent-but-wrong motion no
matter how the output swizzle was twiddled (because the math itself
was incoherent, not the coord system).

Now compute WPO as `target_absolute - bind_real` in bake's glTF
Y-up meters space:
  - target_yup_m = bounds_min + p × range          (absolute, from texture)
  - bind_yup_m = inverse_swizzle(LocalPosition) / 100   (from imported mesh)
  - WPO_unreal_cm = swizzle(target_yup_m - bind_yup_m) × 100

The user-tweakable swizzle params now control only the bake→Unreal
direction mapping (default = Interchange's (X, Z, Y)).

Bumps OPENVAT_BUILD to 14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): inflate bounds_scale=3 so WPO-displaced submeshes don't get culled

The StaticMesh's bounding box is built from the bind pose only —
Unreal has no idea the material's WPO will displace vertices, so
view-frustum culling and per-section visibility checks fire when
animated vertices wander outside that AABB. Symptom: tiny
submeshes (eyes, cigar tip) blink out on specific frames where the
head rotation briefly pushes their bind-pose position outside the
static AABB, even though the WPO would have put them back inside.

3× bounds_scale gives the dance plenty of headroom without hurting
culling perf for the demo's single-actor scene. Bumps OPENVAT_BUILD
to 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): WPO = absolute_target - LocalPosition (no bind round-trip)

Previous shader did target - bind_yup_m where bind_yup_m was
inverse-swizzled from LocalPosition then forward-swizzled back —
mathematically equivalent but introduces extra floating-point
operations that produce sub-mm errors. For Mixamo's eye sclera
shells (which sit fractions of a mm in front of the head plug)
those errors can flip z-order and cause the iris to briefly
disappear on specific frames.

Compute the target absolute position in Unreal cm directly, then
subtract LocalPosition once at the end. Same math for the normal
case, but with one less swizzle round-trip and less precision loss
near coplanar surfaces.

Bumps OPENVAT_BUILD to 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): PixelDepthOffset=-0.5cm to mask 16-bit quantization z-fight

The bake stores positions as uint16 normalized into the dancer's
full bounding range (~2.1m), giving ~0.032mm precision per axis.
Mixamo's eye sphere sits a fraction of a mm in front of the head's
"eye socket plug" submesh, so the per-frame quantization jitter is
on the same order as the eye/plug gap — classic z-fighting that:
  - flickers in/out frame by frame (the "underwater" look)
  - depends on camera angle (depth-test resolution varies)
  - fails asymmetrically (one eye visible, the other not)

Bias the entire material's rendered depth 5mm toward the camera
via PixelDepthOffset so the bake's fragments always win the depth
test against coplanar surfaces behind them. The eye/iris layer is
the only place this matters, and 5mm is well below visible-bias
threshold for the rest of the mesh.

The real fix is a 32-bit float (EXR) bake — would eliminate the
jitter entirely instead of masking it. That requires bake-pipeline
changes in qtmesh (TODO, separate epic). Bumps OPENVAT_BUILD to 17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(vat): 32-bit float EXR bake to eliminate position quantization jitter

The OpenVAT bake has been uint16 PNG since day one. For Mixamo-scale
characters (~2 m bounds) the per-axis quantization step is ~0.03 mm
— same order of magnitude as the gap between coplanar shells (e.g.
Mixamo's eye sphere ~0.5 mm in front of the head's eye-socket plug).
Result on the Unreal demo: per-frame jitter randomly flips the
depth-test outcome, eye/iris flickers in & out depending on camera
angle, dancer appears to be rendered "underwater".

Add a 32-bit-float code path that sidesteps quantization entirely:

  * `qtmesh vat --bake-precision 32` writes `<name>_pos.exr` instead
    of `<name>_pos.png`. EXR stores raw post-skin meters (no
    bounds-min/max remap); precision = float32 (sub-µm at sub-1m
    scales). File ~6× larger than the PNG but still small.
  * Sidecar JSON now carries `_bit_depth: 16|32` so consumers know
    which file to read and how to decode it. Old bakes (no field)
    default to 16. Backward compatible with all existing tooling.
  * Minimal scanline EXR writer (`MinimalEXRWriter.{h,cpp}`) — Qt 6
    has no native EXR plugin and pulling OpenEXR/Imath just for a
    one-off bake would blow up the build matrix, so ship a focused
    ~150 LOC encoder with its own round-trip unit tests.

Wire the Unreal demo to dispatch on `_bit_depth`:

  * `import_bake_assets()` looks for the EXR first, falls back to
    PNG. Texture compression is TC_HDR for EXR (RGBA16F internal,
    preserves float values) vs TC_VECTOR_DISPLACEMENTMAP for PNG.
  * Custom HLSL gets a `decode_mode` scalar param: 0 = remap via
    bounds (PNG path), 1 = raw texel (EXR path).
  * PixelDepthOffset hack from build 17 is now only applied to
    16-bit bakes — the EXR precision eliminates the eye z-fight
    at its source, no bias needed.

Re-baked the demo's Rumba asset at 32-bit so the next project open
picks up the EXR + sidecar automatically. Bumps OPENVAT_BUILD to 18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): force bUseFullPrecisionUVs=True to preserve TEXCOORD_1 > 2048

By default Interchange stores secondary UV channels as half-precision
floats (16-bit). The half-float mantissa is 11 bits, so integers
above 2048 round to the nearest representable value — aliasing
neighbouring TEXCOORD_1 column indices together.

For this Mixamo bake the asset has 5828 vertices, so all columns
past the first ~third get aliased: vertex N (col > 2048) reads
vertex M's animated position where M ≠ N but is the nearest
representable half-float. On most frames this looks fine because
neighbour deltas are small, but on frames where the aliased
neighbour happens to be displaced far from its bind, that vertex
visibly jumps to the wrong spot. Small submeshes whose column
ranges are adjacent to >2048 territory (the cigar at 1438-1549,
sitting right next to skin verts 1550-2855) show the artifact
most clearly because there's no surrounding geometry to mask it.

Set bUseFullPrecisionUVs=True so TEXCOORD_1 is stored as float32
and integer column indices round-trip exactly. Bumps OPENVAT_BUILD
to 19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): re-enable PixelDepthOffset for 32-bit bakes (rasterizer depth precision)

Build 18 disabled the -0.5 cm PixelDepthOffset for 32-bit bakes on
the assumption that vertex-position quantization was the only source
of eye/teeth z-fighting. That was wrong — UE's depth buffer is
screen-space and its precision depends on the camera's near/far
ratio, not vertex precision. Mixamo's eye sclera and dental plug
sit sub-mm inside the head; even perfect float32 vertex positions
can still flip on the depth test when rasterized into a depth
buffer with lower precision than the vertex Z.

Re-apply -0.5 cm always so the VAT-displaced shells reliably win
z-tests against any coplanar static-pose plug behind them. 5mm is
invisible for non-coplanar surfaces. Bumps OPENVAT_BUILD to 20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(unreal-demo): remove PixelDepthOffset — it makes more frames glitch

Build 20 applied -0.5 cm PDO globally on the theory that
rasterizer depth-buffer precision was the bottleneck. In practice
the bias makes things worse: vertices that previously occluded
correctly now z-fight with the OTHER coplanar layer they were
sitting between. More frames glitch, both eyes affected, teeth too.

Drop PDO entirely. The 32-bit EXR bake + full-precision UVs are
the actual fix; remaining sub-mm glitches will be diagnosed
properly (likely Interchange vertex de-dup collapsing the eye
sclera's duplicate-position verts) instead of masked.

Bumps OPENVAT_BUILD to 21.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): per-slot PDO — M_OpenVAT_Eye applied only to Eyes_MAT slot

Eye z-fight diagnosis from video frames 1, 2, 4, 5, 6: the iris/pupil
submesh and the head's eye-socket plug behind it sit coplanar at sub-mm
depth. Even with float32 EXR positions, the rasterized depth buffer
can't reliably keep them apart from view-dependent angles — one frame
the iris wins, the next the plug wins (visible as a white sclera).

Global PixelDepthOffset (build 20) made it worse because it biased
ALL fragments forward, including the head plug itself, which then
z-fights with the OTHER face layers it was previously occluding.

Per-slot fix:
  * Build M_OpenVAT_Eye by duplicating M_OpenVAT and adding a
    PixelDepthOffset = -1 cm constant.
  * On spawn, walk static_materials slot-by-slot. Slots whose
    `material_slot_name` contains "Eyes_MAT" get M_OpenVAT_Eye;
    every other slot gets the original M_OpenVAT.
  * 1 cm is below the visible-bias threshold for the eye region
    (the eye sphere is the deepest layer there — no other coplanar
    geometry to fight with from this push direction).

Teeth share Skin_MAT with the rest of the face, so we can't isolate
them with the same per-slot trick. The teeth-distortion-on-some-
frames is left as a separate (smaller) bug. Bumps OPENVAT_BUILD to 22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(unreal-demo): undo per-slot eye PDO — made ear/teeth glitch too

Build 22's M_OpenVAT_Eye with PDO=-1cm on the Eyes_MAT slot pushed
the iris fragments forward, which is correct for the eye-vs-plug
z-fight in isolation — but it also moved the iris vertices forward
of the ear and tooth submeshes that previously occluded them
correctly from oblique camera angles. Result: more frames glitch,
ears flicker.

Every PDO push reveals another coplanar layer behind. PDO is the
wrong tool for this geometry. Drop M_OpenVAT_Eye, return to plain
M_OpenVAT on every slot. The remaining glitch (sub-mm coplanar
z-fight on eye/teeth from certain angles) needs a real diagnosis
of WHY UE's static-mesh build is destabilising those verts —
likely vertex de-dup with TEXCOORD_1 not part of the equality key.

Bumps OPENVAT_BUILD to 23 and pre-cleans the stale M_OpenVAT_Eye
asset for users who already ran build 22.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): TC_HDR_F32 (RGBA32F) for EXR position texture

TC_HDR is RGBA16F — half-precision float. We've been writing 32-bit
floats to the EXR (the whole point of the precision overhaul) only
to have UE round them back down to half-precision on GPU upload.
Verified in UE 5.7's TextureDefines.h:
  TC_HDR     → RGBA16F (~3 mm precision at head height)
  TC_HDR_F32 → RGBA32F (sub-µm precision)

At head height (Y ≈ 1.65 m) the gap between adjacent half-floats is
~2 mm, which is plenty to flip the depth-test outcome on coplanar
eye/teeth/ear sub-meshes from frame to frame. That's exactly the
"specific-frames glitch" pattern from the video.

Switch the EXR-import compression to TC_HDR_F32. Falls back to
TC_HDR if the enum value isn't exposed in older engines (5.4 and
below). Bumps OPENVAT_BUILD to 24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): two_sided=True — bind-pose normals back-face-cull rotated WPO verts

The mesh's vertex normals are baked from the BIND pose. The Custom
HLSL drives WPO (positions move per-frame) but never updates the
normals — so when the dance rotates the head/face 90°+ from bind,
small sub-meshes (eyes, teeth, ears) whose bind-pose normals
straddle the camera-facing threshold get back-face culled even
though their WPO-displaced positions are toward the camera.

Symptom: specific frames render the rotated face WITH eye/teeth
sub-meshes missing — the missing region was culled by a rasterizer
back-face test that used the wrong (bind-pose) normal direction.

two_sided draws both faces unconditionally, which is the correct
behaviour for any mesh whose normals don't track WPO. Costs a bit
of fill rate, no lighting artifact at this distance.

The complete fix would be to also output VAT-driven world-space
normals from the bake's lower-half normal texture, but two-sided is
the load-bearing fix; computed normals are a polish pass.

Bumps OPENVAT_BUILD to 25.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): force bUseFullPrecisionUVs on StaticMesh BuildSettings + rebuild

User's observation: ONLY the head submeshes glitch, body fine. That
matches the col-index-above-2048 hypothesis (Skin0=1550-2855,
Skin1=2856-3236, Skin2=3877-4257, Eyes0=3691-3783, Eyes1=3784-3876
are all > 2048; Clothes/Cigar are mostly below). Half-float UV
storage rounds those columns to the nearest representable
half-precision value, aliasing head verts onto each other's
animation data.

We were already setting bUseFullPrecisionUVs=True on the Interchange
pipeline at import time, but that flag doesn't reliably propagate
into the StaticMesh's per-LOD FMeshBuildSettings — the StaticMesh
ends up keeping its default (half-precision) on rebuild. Belt-and-
braces: walk every LOD's BuildSettings, flip the flag, call build()
+ save. Two-sided remains from build 25.

Bumps OPENVAT_BUILD to 26.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): use StaticMeshEditorSubsystem to flip bUseFullPrecisionUVs

Build 26 tried mesh.get_lod_build_settings(lod) — but that method
doesn't exist on UStaticMesh, the API lives on the
StaticMeshEditorSubsystem with the build settings passed/returned
by reference. Log was silent because the hasattr() check bailed.

Use the correct subsystem API now. If the flag was already set by
Interchange, log confirms and skip the rebuild. If not, flip + call
mesh.build() + save. Bumps OPENVAT_BUILD to 27.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): correct get_lod_build_settings call signature

Build 27's log showed:
  get_lod_build_settings() takes at most 2 arguments (3 given)

UE 5.7's Python binding flattens the C++ out-parameter signature:
  C++:    void GetLodBuildSettings(mesh, lod, OutSettings&)
  Python: settings = sme.get_lod_build_settings(mesh, lod)

Drop the 3rd arg and capture the return. Bumps OPENVAT_BUILD to 28.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): disable normal/tangent recompute + bounds_scale=10

The log confirmed bUseFullPrecisionUVs was already on, so half-float
UV aliasing isn't the bug. Remaining suspects for the head-only
glitch:

1. UE's StaticMesh build re-runs Mikkt tangent-space generation
   based on the imported UV0 + position. Mixamo's head has hundreds
   of UV-seam-split verts at the same position with different UV0/
   normal — Mikkt's edge cases on those can produce inconsistent
   tangents across adjacent triangles, which the rasterizer can use
   for back-face decisions on screen-space-degenerate triangles.
   Disable bRecomputeNormals + bRecomputeTangents so Mixamo's
   already-correct attributes pass straight through.

2. bounds_scale=3 might not cover hand-swing extremes. Bump to 10.

Bumps OPENVAT_BUILD to 29.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): bUseHighPrecisionTangentBasis=True for residual head glitch

Build 29 fixed most of the head glitch by disabling
bRecomputeNormals/bRecomputeTangents. User reports 1 eye + 1 ear
still flicker on fewer frames — an asymmetric residual.

UE stores tangents as 8-bit per axis by default. Mixamo's head has
hundreds of UV-seam-split verts whose imported tangents are nearly
but not exactly identical across adjacent triangles. After 8-bit
quantization, the differences land on different quantized values
some triangles, and the rasterizer's edge equations occasionally
disagree on which side wins — producing the asymmetric per-frame
glitch.

bUseHighPrecisionTangentBasis stores tangents at 16-bit per axis,
removing that quantization step. Costs ~2x tangent buffer (still
tiny vs the position+UV+color buffers). Bumps OPENVAT_BUILD to 30.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): drive Normal output from the bake's normal half (world-space)

User: "blinking depending on angle, could it be normals?" — yes. The
bake stores per-frame normals in rows frame_count..2*frame_count-1
of the position texture, but until now the material left the mesh's
NORMAL output at the bind-pose vertex normal. WPO moves the vertex
positions correctly, but UE's shading + depth-pre-pass tests use
the static bind-pose normal — so small submeshes (eyes, ears, teeth)
whose bind normals briefly straddle the camera-facing threshold
during head rotation get discarded by view-dependent fragment tests.

Add a second Custom node that decodes (n+1)/2 → n from the bake's
normal half, applies the same Y-up→Z-up swizzle as positions, and
returns a unit world-space normal. Set the material's
tangent_space_normal=False so UE treats the output as world-space
(otherwise it would apply the inverse tangent basis and turn our
world normal into garbage).

Bumps OPENVAT_BUILD to 31.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "fix(unreal-demo): drive Normal output from the bake's normal half (world-space)"

This reverts commit 47f74c0360d55414ae13a9f24e9d0d185a6e74c7.

* chore(unreal-demo): bump OPENVAT_BUILD to 32 so the build-31 revert takes effect

The revert restored the script to build-30 semantics, but the stale
M_OpenVAT asset on disk is stamped with 31 — init_unreal's
"stamped < expected" check would skip rebuilding. Bumping to 32
forces the rebuild back to the build-30 state we know is the best
result so far.

Save-point tag unreal-demo-saving-point-build-30 still points at
the materially identical build-30 commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): linux test link + address code-review feedback

CI fix:
- Add src/MinimalEXRWriter.cpp to tests/CMakeLists.txt so the three
  MaterialEditorQML test binaries link against VATBaker::bake's
  MinimalEXR::writeRGB32F reference. Was only in src/CMakeLists.txt.

Code-review applied (still-valid items only; earlier comments about
UV2 baking failure / non-idempotent material build / success-banner
guards / spawn-failure aborts are already implemented in current code):

- #7  Missing T_OpenVAT_Pos/T_Boss_Diffuse now hard-stops the
       bootstrap. Previously only the mesh check aborted, so a failed
       texture import could still reach the success banner with an
       unbound `pos_tex` and produce a dancer frozen in bind pose.
- #8  verify_imported_uv_channels now returns bool, and main()
       aborts when it returns False (a SkeletalMesh import despite
       our static-mesh override). Stops the script from continuing
       with a known-bad import.
- #6  spawn_dancer_in_level: when get_all_level_actors() raises,
       return None instead of falling back to [] — the empty list
       skipped the cleanup pass and allowed reruns to stack
       duplicate OpenVAT_Dancer actors. main() already handles None
       via the partial-failure path.
- #3  Replaced the silent `except Exception: pass` on actor
       destruction with a log_warning so cleanup failures are
       diagnosable instead of swallowed.
- #9  init_unreal.py: replaced the hard-coded mesh path tuple with
       a recursive AssetRegistry sweep under /Game/Rumba/ matching
       any StaticMesh or SkeletalMesh. Keeps the "skip if mesh
       present" decision in sync with build_vat_demo's
       find_imported_mesh, so a non-canonically-named import
       doesn't trigger a rebuild every editor open.

Bumps OPENVAT_BUILD to 33.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(unreal-demo): OpenVAT-style normal chain (OS → Transform Local→Tangent)

Followed the canonical pattern from sharpen3d/openvat's Unreal
reference material (OpenVAT_Basic.uasset). The earlier world-space
attempt (build 31) failed because setting
tangent_space_normal=False breaks subtle parts of the lighting
evaluation chain.

New chain:
  Custom HLSL (returns object-space normal in Unreal local cm)
    └─► MaterialExpressionTransform (Source=Local, Dest=Tangent)
          └─► MP_NORMAL
  tangent_space_normal stays True (the material default)

The Custom node samples the bake's lower-half per-frame normal,
decodes (n+1)/2 → n in [-1..1], and applies the same swizzle the
WPO uses to land in Unreal's local space (Interchange swizzles
positions and normals together on import, so the same matrix
serves both).

The Transform node rotates the OS delta normal into the per-pixel
tangent basis UE's lighting expects with tangent_space_normal=True.
This is the exact chain documented in OpenVAT's Unreal walkthrough
(https://youtube.com/watch?v=T1KVvUIduGI), now mirrored in code so
small head submeshes (eyes, ears) don't blink on rotated frames
because their bind-pose normals diverge from the WPO-displaced
surface.

Bumps OPENVAT_BUILD to 34.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Revert "feat(unreal-demo): OpenVAT-style normal chain (OS → Transform Local→Tangent)"

This reverts commit c0a247c36005990be1292b358efa88dfd3976d52.

* chore(unreal-demo): bump OPENVAT_BUILD to 35 so the build-34 revert takes effect

The build-34 normal chain (Custom HLSL OS-normal → Transform
Local→Tangent → MP_NORMAL) failed Material compile on SF_METAL_SM6:

  Failed to compile Material for platform SF_METAL_SM6,
  Default Material will be used in game.

UE substituted its default checkered material — no texture, no WPO,
no animation. Most likely the Transform node's compile path requires
a tangent basis flow that doesn't connect cleanly when its input
comes from a vertex-shader Custom node; needs more investigation
before re-attempting.

The c0a247c revert restored the build-30-equivalent code to OPENVAT_BUILD
= 33, but the stale M_OpenVAT on disk is stamped 34, so init_unreal's
"stamped < expected" check would skip rebuilding. Bumping to 35 forces
the rebuild to the build-30 known-good state ("1 eye + 1 ear blink on
specific angles, otherwise clean").

Save-point tag unreal-demo-saving-point-build-30 still points at the
materially identical state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(unreal-demo): add language tag to tree-listing fence + update for EXR bake

Two cleanups:
- CodeRabbit #5: the directory-tree fenced block lacked a language
  tag, tripping MD040. Added `text`.
- Tree caption mentioned `mixamo.com_pos.png` and "16-bit" — the
  demo now ships an EXR 32-bit bake (kept the PNG note as a still-
  valid alternative).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(exr-writer): bounded scan instead of unbounded strlen (Sonar S5813)

SonarCloud flagged a buffer-overflow security hotspot at line 33 —
strlen on a const char* with no upper bound. All current callers
pass string literals, so it's a defensive measure, but it's also
trivially easy to make actually safe.

Replace `std::strlen` with a manual bounded scan (kMaxAttrLen=256).
Portable across platforms (avoids strnlen which isn't on the std
side on all toolchains), null-safe, and clears the hotspot.

Verified: app + qtmesh CLI rebuild, EXR bake still produces a valid
OpenEXR file (file(1) confirms magic + structure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(unreal-demo): swap to Mixamo Hip Hop Dancing (1 submesh, simpler geometry)

The Rumba Dancing model has 11 primitives merged into 4 material
sections (Skin_MAT, Cigar_Mat, Clothes_MAT, Eyes_MAT), with hundreds
of UV-seam-split verts in the head. That layout amplified every
precision/tangent-quantization issue we hit (1 eye + 1 ear blink
on certain frames remained even after the full build-30 fix stack).

Hip Hop Dancing is a single-submesh single-material character —
6968 verts, 134 frames @ 30fps, 1 material slot (Ch14_Body). No
inter-submesh layering means no Eyes_MAT-vs-Skin_MAT z-fight, no
material-merge-driven section coalescing, and no asymmetric
seam-split-tangent surprises. The smaller mesh is also closer to
what a typical game-character VAT use case looks like.

Changes:
- Replace Content/Rumba/* bake artifacts with the Hip Hop bake
  (qtmesh vat ... --bake-precision 32 --emit-uv2)
- build_vat_demo.py: auto-detect the diffuse PNG by searching for
  a file matching *diffuse*.png or *albedo*.png in the bake dir,
  instead of hard-coding Boss_diffuse.png. Falls back to the first
  non-bake-artifact PNG if no name match.
- Force-add Ch14_1001_Diffuse.png even though the repo's root
  .gitignore had an entry for that name (it pre-existed from
  unrelated test assets in /).
- Bump OPENVAT_BUILD to 36 so init_unreal rebuilds against the new
  bake on next open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): use post_edit_change() to trigger StaticMesh rebuild

Log from the Hip Hop swap showed:
  verify_uv: rebuild failed: 'StaticMesh' object has no attribute 'build'

UE 5.7's Python binding doesn't expose UStaticMesh.build() — the
right way to trigger a rebuild after editing per-LOD BuildSettings
is post_edit_change(), which fires PostEditChangeProperty and
re-cooks the runtime vertex/index buffers from the source.

Without the rebuild, our bUseFullPrecisionUVs + recompute_normals
=False + recompute_tangents=False + use_high_precision_tangent_basis
edits applied to the source-data side but never reached the runtime
buffers — meaning the demo has been rendering with default settings
all along even though the script claimed to have flipped them.

This may be the actual root cause of the residual artifact (now
visible on the single-submesh Hip Hop mesh too). With the rebuild
firing, the high-precision tangents + non-recomputed normals should
finally take effect.

Also drop the get_section_info dump that was raising on every load
(it lives on StaticMeshEditorSubsystem, not UStaticMesh; not
load-bearing for the demo). Bumps OPENVAT_BUILD to 37.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): emitGltfUv2 wrote bake col = gltfIdx instead of Ogre col

emitGltfUv2 was supposed to make each glTF vertex point at the bake
column for its corresponding Ogre vertex. The permutation built by
buildVertexPermutation says "Ogre vert i lives at glTF position j"
(perm[i] = j). To make glTF vert j read bake col i, we'd write
col = i at output offset j. The code did:

    col = gltfIdx % texWidth    // = j

…meaning glTF vert j reads bake col j, which is Ogre vert j's
animation — NOT Ogre vert i's. On meshes where Assimp's gltf2
exporter actually permutes vertices via JoinIdenticalVertices, this
wired every glTF vert to the wrong vertex's animation, with the
visible result of body parts flying apart on frames where the
wrong-source motion was large.

Mixamo's Hip Hop Dancing turns out to have no exact-duplicate
verts → identity permutation → this bug was inert for that asset.
But the fix is still correct for the general case (and surfaces on
assets where verts DO collapse). Diagnostic for the asset went from
"verify: identity perm, bake col 5108 doesn't match gltf vert 5108
bind" → still doesn't match because mixamo frame 0 is mid-dance,
not bind — that comparison was a red herring.

Bumps OPENVAT_BUILD to 38. The fox-glitch root cause remains under
investigation; next step is a fresh screenshot to pin the symptom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(unreal-demo): drop dead post_edit_change call — SetLodBuildSettings already rebuilds

Log on 74db228 showed:
  verify_uv: rebuild failed: 'StaticMesh' object has no attribute 'post_edit_change'

Reading UStaticMeshEditorSubsystem::SetLodBuildSettings
(5.7/StaticMeshEditorSubsystem.cpp:586) reveals that it ALREADY
calls StaticMesh->PostEditChange() internally — so the rebuild
fires the moment sme.set_lod_build_settings() returns. Our extra
post-call was redundant, and the fact that it raised every time
just made the log noisy; the actual rebuild has been working
correctly across every recent run.

Drop the manual call, keep the save. Bumps OPENVAT_BUILD to 39.

Build-settings fixes (UV precision, no-recompute-normals/tangents,
high-precision tangents) have been in effect all along — meaning
the residual artifact is NOT a precision issue. Investigation
continues; awaiting a fresh screenshot to pin the symptom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* diag(unreal-demo): log imported mesh's render-vertex count

User screenshot shows the fox's left eye iris missing (a hole
through the head where the iris triangle should be). The bake
data is verified clean (6968 verts, no outliers, smooth motion).
The glTF↔bake column mapping is verified identity. The build
settings (full-precision UVs, no recompute) are verified applied.
The material is two-sided.

Working theory: Interchange's static-mesh build merges verts that
have identical position + normal + UV0 EVEN IF they have distinct
TEXCOORD_1 values, because UV-channel-1 isn't part of the merge
equality key. On Mixamo's fox there are 62 coplanar clusters in
the head region (UV-island-split verts at the same XYZ for the
texture seam between iris and sclera). If those merge to one
render-buffer vert, the surviving vert keeps one TEXCOORD_1 → both
sides of the seam read the same bake column → the iris-side
triangle collapses to the sclera-side position on frames where
the difference is non-trivial → iris hole.

Log the imported StaticMesh's render-buffer vertex count. If it's
less than the source bake's 6968, this theory is confirmed and
the fix is a different vertex-disambiguation path (vertex color,
or guarantee distinct UV0 per cluster, or pre-split the source).
Bumps OPENVAT_BUILD to 40.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* revert(unreal-demo): swap back to Rumba — Hip Hop showed same artifacts

The Hip Hop swap was meant to side-step the Rumba multi-submesh
coplanar-z-fight by using a 1-submesh-1-material mesh. In practice
the same eye-iris-missing pattern appeared on Hip Hop too, because
Mixamo's character meshes have UV-island-split verts in the head
region (62 coplanar clusters on Hip Hop, similar on Rumba) — the
'single submesh' didn't actually eliminate the layering, the seam
just lives inside one render section instead of between two.

User reports Rumba behaviour was 'better' (presumably the fewer-
affected-frames + only-one-eye-at-a-time pattern from build 30).
Restore the Rumba bake artifacts from 9eb70fe — all the script
improvements between then and now stay (Hip Hop import path is
intact in case someone wants to try again, plus the auto-detected
diffuse PNG path, the post-edit-change cleanup, etc.).

Bumps OPENVAT_BUILD to 41 so init_unreal rebuilds against the
restored Rumba bake.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant