Skip to content

vat: collapse to OpenVAT, drop per-engine targets - #620

Merged
fernandotonon merged 4 commits into
masterfrom
vat/openvat-only
May 19, 2026
Merged

vat: collapse to OpenVAT, drop per-engine targets#620
fernandotonon merged 4 commits into
masterfrom
vat/openvat-only

Conversation

@fernandotonon

@fernandotonon fernandotonon commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

  • VAT has one output format now: OpenVAT (sharpen3d/openvat). The unity/unreal/godot/agnostic targets and the rgba8/rgba16 encoding switch and the --normals flag are all gone.
  • Output per bake is two files:
    • <basename>_pos.png — 16-bit RGB, width = vertexCount, height = 2 × frameCount, packed (top half positions, bottom half normals)
    • <basename>-remap_info.json — canonical { "os-remap": { Min, Max, Frames } } sidecar with stringified 8-decimal-place floats, bounds rounded outward to 0.1
  • Off-the-shelf openvat reference shaders for Godot / Unity / Unreal / Blender consume the output unmodified.

Why

The pre-existing per-engine targets were all "QtMeshEditor's own bake" variants that only our handwritten harness shaders consumed. None dropped into a stock engine project. OpenVAT is the cross-engine community convention with real consumer shaders. Real downstream readers exist; emit what they read.

Breaking

qtmesh vat flag removals: --target, --encoding, --normals. The bake_vat MCP tool schema loses the same. VATBakerController::bake() goes from 7 args to 4. VATBaker::Options drops target/encoding/bakeNormals. VATBaker::BakeResult drops nrmTexPath/unityMetaPath/godotShaderPath. Public encode/decode static helpers on VATBaker are removed.

No external consumers — VAT has not been released yet.

Verified

$ qtmesh vat "Rumba Dancing.fbx" --anim "mixamo.com" -o /tmp/v
Baked OpenVAT for 'mixamo.com' (71 frames × 5828 vertices)
  texture:  /tmp/v/mixamo.com_pos.png
  sidecar:  /tmp/v/mixamo.com-remap_info.json
$ file /tmp/v/mixamo.com_pos.png
mixamo.com_pos.png: PNG image data, 5828 x 142, 16-bit/color RGB, non-interlaced

Follow-up

The Godot / Unity test harnesses (tools/godot-vat-test/, tools/unity-vat-test/) need new runtime shaders that consume the packed-normals texture + os-remap sidecar. The staging shell scripts already pass through to the new CLI shape; only the GDScript/C# shaders need a rewrite. Not blocking — real users get canonical OpenVAT.

Test plan

  • CI green across all platforms
  • New tests: OpenVATSidecarMatchesReferenceShape, OpenVATBoundsRoundedOutwardToTenth, OpenVATTextureIs16BitRgb
  • Smoke test: bake Rumba Dancing, verify file dimensions + sidecar shape

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • VAT baking now outputs OpenVAT: single packed 16-bit RGB PNG plus canonical remap JSON.
  • Refactor / Changes

    • Baking UI/CLI and APIs simplified: removed encoding/target/normals options; required animation + optional FPS, output, basename; default output dir provided.
  • Documentation

    • Updated READMEs, editor helpers and shader docs to describe OpenVAT layout and playback expectations.
  • Tests

    • Updated unit and end-to-end tests and harnesses for the OpenVAT contract.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@fernandotonon has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 49 minutes and 41 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 50d2f091-c80c-44ac-a5ef-c0542b152c03

📥 Commits

Reviewing files that changed from the base of the PR and between 899477d and 5ded8c0.

⛔ Files ignored due to path filters (1)
  • tools/unity-vat-test/Assets/VAT/Shaders/VAT.shader is excluded by !**/*.shader
📒 Files selected for processing (1)
  • tools/godot-vat-test/scripts/VATPlayer.gd
📝 Walkthrough

Walkthrough

This PR converts VAT baking to an OpenVAT-only pipeline: it simplifies VATBaker public types, implements a 16-bit packed position+normal PNG and an os-remap sidecar, enforces normals and outward-rounded bounds, updates CLI/MCP/controller/QML integrations to the reduced contract, expands tests, and adapts Godot and Unity test harnesses to load the OpenVAT outputs.

Changes

OpenVAT-only VAT API and encoder core

Layer / File(s) Summary
VATBaker public API simplification
src/VATBaker.h
Removed public enums and encoding/target/bakeNormals options; simplified Options and BakeResult, added frameCount/vertexCount/minBound/maxBound, and documented the OpenVAT output contract.
OpenVAT encoder and sidecar internals
src/VATBaker.cpp
Added buildOpenVATSidecar(...) generating os-remap with outward-rounded stringified Min/Max and _producer/_axes; added packOpenVAT16(...) to pack positions (top half) and normals (bottom half) into a single 16-bit buffer; removed legacy RGBA encoders.
Headless bake correctness and collection
src/VATBaker.cpp
After setting animation time, mark animation state dirty and queue frame render to recompute skinning; always collect and validate normals; compute/pad bounds; snap bounds outward to 0.1 grid; write <basename>_pos.png (QImage::Format_RGBX64, height 2*frameCount) and <basename>-remap_info.json.

Integration layers (CLI, MCP, QML, Controller)

Layer / File(s) Summary
CLIPipeline cmdVat updates
src/CLIPipeline.cpp
vat CLI help/usage simplified to --anim, optional --fps, -o/--output, and --json; enforce --anim, compute default output dir, and emit JSON/human output with texture, sidecar, frameCount, vertexCount, animation, fps, and bounds.
MCPServer toolBakeVat updates
src/MCPServer.cpp
Tool parsing now uses only fps, output_dir, and basename; updates tool registry metadata and success JSON payload to the OpenVAT schema.
VATBakerController API and directory picker
src/VATBakerController.h, src/VATBakerController.cpp
bake(...) signature dropped encoding/target/bakeNormals parameters; Options now populated with fps, outputDir, basename; added chooseOutputDir(startDir) for Qt directory selection with seed/fallbacks.
QML Animation Mode Tools UI
qml/PropertiesPanel.qml
Moved VAT bake UI into animationModeToolsComponent under Animation Mode Tools, bound to VATBakerController animations list, FPS, output directory chooser, and the simplified bake action.

Test coverage updates

Layer / File(s) Summary
VATBakerController test updates
src/VATBakerController_test.cpp
Updated tests to call the reduced-parameter bake overload; preserved validation and bakeFinished assertions.
VATBaker unit and integration tests
src/VATBaker_test.cpp
Added test validating buildSidecarJson emits os-remap with string Min/Max; tightened E2E tests to assert -remap_info.json naming, packed texture height 2*frameCount, adjacent-row distinctness, bounds rounding to 0.1 grid, and 16-bit RGB image formats.

Godot 4 OpenVAT test harness

Layer / File(s) Summary
Godot docs and staging
tools/godot-vat-test/README.md, tools/godot-vat-test/bake_and_stage.sh
README updated to document OpenVAT outputs and packed layout; bake_and_stage.sh simplified to accept --fps and call qtmesh vat with --anim/--fps/-o.
Godot VATPlayer script
tools/godot-vat-test/scripts/VATPlayer.gd
Load *-remap_info.json and matching *_pos.png, parse os-remap Frames/Min/Max, validate pos texture height 2×Frames, create per-surface ShaderMaterials with vertex_offset, use fps_override for playback, and shader samples positions (top) and normals (bottom).

Unity 2022 OpenVAT test harness

Layer / File(s) Summary
Unity project config and docs
tools/unity-vat-test/Packages/manifest.json, tools/unity-vat-test/ProjectSettings/ProjectVersion.txt, tools/unity-vat-test/README.md
Set Unity version metadata and added com.unity.cloud.gltfast; README updated to explain OpenVAT playback and per-submesh _VertexOffset mapping.
Unity staging and editor tool
tools/unity-vat-test/bake_and_stage.sh, tools/unity-vat-test/Assets/VAT/Editor/VATTestSetup.cs
Add staging script for Unity bakes and update VATTestSetup to discover *_pos.png, *-remap_info.json, and source.gltf, normalize asset paths, and remove normal-texture wiring.
Unity VATPlayer component
tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs
Parse hyphenated os-remap JSON block into OpenVATBlock, derive frame/vertex counts and bounds, apply import settings, construct per-submesh materials binding only posTex and _VertexOffset, expand mesh bounds, and advance frames using fpsOverride.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

"I nibble bytes in twilight's light,
Packing normals, positions tight.
One PNG, one sidecar JSON, neat—
OpenVAT hops home on velvet feet. 🐰"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'vat: collapse to OpenVAT, drop per-engine targets' clearly and concisely summarizes the main change: the VAT system has been consolidated to use only the OpenVAT format with removal of per-engine targets.
Description check ✅ Passed The PR description includes a comprehensive summary of changes, technical justification (Why section), documentation of breaking changes, verification example, and test plan—covering the repository's template sections effectively.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vat/openvat-only

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14e6deae8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/VATBaker.cpp Outdated
.arg(result.posTexPath);
return result;
}
auto packed = packOpenVAT16(flat, normals, frameCount, vertexCount, lo, hi);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encode positions against the rounded OpenVAT bounds

For any animation whose sampled min/max is not already exactly on the 0.1 OpenVAT grid, this packs the PNG using the exact lo/hi while the JSON sidecar later reports rounded-outward bounds. Consumers decode the texture with the sidecar os-remap values, so a vertex at the true max (for example 1.03) is written as 65535 and then decoded as the rounded max (1.10), scaling/offsetting the whole animation. The packer and sidecar need to use the same rounded bounds.

Useful? React with 👍 / 👎.

Comment on lines +140 to +144
# Required fields per QtMeshEditor VAT v1 sidecar contract.
for required_key in ["frameCount", "vertexCount", "fps", "bounds", "posTexture"]:
if not sidecar.has(required_key):
push_error("VATPlayer: sidecar missing '%s' key" % required_key)
return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse the OpenVAT os-remap sidecar in the Godot harness

The staging script now calls qtmesh vat, which emits <basename>-remap_info.json containing only os-remap plus _origin, but this loader still requires the old QtMeshEditor keys (frameCount, vertexCount, fps, bounds, posTexture). As a result, every freshly staged OpenVAT bake fails here with “sidecar missing 'frameCount'” before the texture is loaded, so the Godot verification project cannot replay the output this commit generates.

Useful? React with 👍 / 👎.

Comment on lines +117 to +119
_sidecar = JsonUtility.FromJson<VATSidecar>(sidecarJson.text);
if (_sidecar == null || _sidecar.frameCount <= 0 || _sidecar.vertexCount <= 0) {
Debug.LogError($"[VATPlayer] {name}: malformed sidecar JSON");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update Unity VATPlayer for os-remap sidecars

Fresh bakes staged by tools/unity-vat-test/bake_and_stage.sh now contain the OpenVAT os-remap JSON, not the legacy fields modeled by VATSidecar. JsonUtility will therefore leave frameCount/vertexCount at 0 and bounds null, causing RebuildMaterials() to reject the bake as malformed (or fail later if the guard changes), so the Unity test project cannot load the output produced by this commit.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/VATBaker_test.cpp (1)

95-98: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add mesh-file prerequisite assertion in fixture setup.

VATBakerEndToEndTest::SetUp() should also assert canLoadMeshFiles() so Ogre-dependent failures abort early with a clear cause.

Suggested patch
 void SetUp() override
 {
     ASSERT_TRUE(tryInitOgre()) << "Ogre init required";
+    ASSERT_TRUE(canLoadMeshFiles()) << "Mesh file loading required";
 }

Based on learnings: "fixture SetUp must fail loudly in CI by using ASSERT_TRUE(tryInitOgre()) and ASSERT_TRUE(canLoadMeshFiles())."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/VATBaker_test.cpp` around lines 95 - 98, In VATBakerEndToEndTest::SetUp()
add an assertion for mesh-file availability: after the existing
ASSERT_TRUE(tryInitOgre()) << "Ogre init required"; call
ASSERT_TRUE(canLoadMeshFiles()) << "Mesh files required"; so the fixture fails
loudly when mesh assets are missing; update the SetUp override to call
canLoadMeshFiles() alongside tryInitOgre().
src/MCPServer.cpp (1)

4262-4277: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return the OpenVAT artifact metadata as structured fields, not a JSON string.

This currently hides texture, sidecar, frameCount, vertexCount, animation, fps, and bounds inside content[0].text, so MCP/HTTP clients lose the machine-readable bake_vat contract and would have to parse pretty-printed text. Other structured tools in this file expose their payload at the top level alongside content; bake_vat should do the same.

Proposed fix
-    QJsonObject content;
-    content["ok"]          = true;
-    content["texture"]     = result.posTexPath;
-    content["sidecar"]     = result.jsonPath;
-    content["frameCount"]  = result.frameCount;
-    content["vertexCount"] = result.vertexCount;
-    content["animation"]   = animName;
-    content["fps"]         = fps;
+    QJsonObject payload;
+    payload["ok"]          = true;
+    payload["texture"]     = result.posTexPath;
+    payload["sidecar"]     = result.jsonPath;
+    payload["frameCount"]  = result.frameCount;
+    payload["vertexCount"] = result.vertexCount;
+    payload["animation"]   = animName;
+    payload["fps"]         = fps;
     QJsonObject bounds, lo, hi;
     lo["x"] = result.minBound.x; lo["y"] = result.minBound.y; lo["z"] = result.minBound.z;
     hi["x"] = result.maxBound.x; hi["y"] = result.maxBound.y; hi["z"] = result.maxBound.z;
     bounds["min"] = lo; bounds["max"] = hi;
-    content["bounds"] = bounds;
-
-    return makeSuccessResult(
-        QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Indented)));
+    payload["bounds"] = bounds;
+
+    QJsonObject response = makeSuccessResult(
+        QString("Baked OpenVAT '%1' (%2 frames × %3 vertices)")
+            .arg(animName)
+            .arg(result.frameCount)
+            .arg(result.vertexCount));
+    for (auto it = payload.begin(); it != payload.end(); ++it)
+        response.insert(it.key(), it.value());
+    return response;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/MCPServer.cpp` around lines 4262 - 4277, The code is embedding the
bake_vat metadata as a pretty-printed JSON string inside the response instead of
returning structured fields; update the handler to return the metadata as
top-level JSON members. Replace the current QJsonObject content ->
QJsonDocument(...).toJson(QString) usage by constructing a top-level QJsonObject
(or reuse content) that sets "ok", "texture" (result.posTexPath), "sidecar"
(result.jsonPath), "frameCount" (result.frameCount), "vertexCount"
(result.vertexCount), "animation" (animName), "fps" (fps) and "bounds" (with
min/max using result.minBound/result.maxBound) and pass that structured object
into makeSuccessResult so clients receive machine-readable fields instead of a
pretty-printed text blob; keep references to makeSuccessResult, content, result,
animName and fps to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/VATBaker_test.cpp`:
- Around line 306-314: The test currently uses a hardcoded outputDir "/tmp";
replace this with a QTemporaryDir instance, assert the temp dir is valid (e.g.,
Q_ASSERT(temp.isValid()) or EXPECT_TRUE(temp.isValid())), set opts.outputDir to
the temporary directory path (use temp.path()), and then call
VATBaker::bake(entity, opts) as before; keep references to VATBaker::Options,
opts.outputDir, and VATBaker::bake so the change is localized and the temporary
directory is automatically cleaned up when the QTemporaryDir goes out of scope.
- Around line 173-176: The test compares adjacent rows using QImage::pixel(),
which quantizes 16-bit Format_RGBX64 data to 8-bit and can hide differences;
change the comparison to use QImage::constScanLine(row) and cast the returned
pointer to QRgba64* to compare full 16-bit pixels directly (replace uses of
png.pixel(col, row) / png.pixel(col, row + 1) in the nested loop that sets
foundDifference with 16-bit reads from constScanLine for rows `row` and
`row+1`), ensuring you still iterate over png.width() columns and compare the
appropriate QRgba64 elements.

In `@tools/godot-vat-test/bake_and_stage.sh`:
- Around line 40-44: The --fps case in the argument parsing (inside the
while/case block handling --fps and assigning FPS) needs an explicit arity check
to avoid failing under set -u when no value is provided; before assigning
FPS="$2" (in the --fps branch) validate that a second argument exists and is not
another option (e.g., check that $# -ge 2 and "$2" does not start with --), and
if the check fails print a usage/unknown-arg error to stderr and exit with
status 2.

In `@tools/godot-vat-test/README.md`:
- Around line 65-84: The fenced code block in tools/godot-vat-test/README.md is
missing a language identifier (triggering markdownlint MD040); edit the
README.md and change the opening fence from ``` to a language-tagged fence such
as ```text or ```none (or ```bash if you prefer) so the project-layout block is
explicitly labelled, keeping the same block contents and formatting.
- Line 37: Update the README examples and verification text that reference the
old OpenVAT flags/outputs: remove mentions of the deprecated flags (--encoding,
--target) and any references to separate normal outputs like `_nrm.png` or
generated `.gdshader`; instead show the new OpenVAT outputs (`*_pos.png` which
contains packed normals) and the `*-remap_info.json` file. Replace the example
command line shown (the line containing "media/models/Twist Dance.fbx"
"mixamo.com" --encoding rgba8 --fps 24) with the simplified OpenVAT invocation
(omit --encoding/--target) and then update the verification steps that mention
`_nrm.png`, `.gdshader`, and per-texture target flags in the sections called out
(around the example lines and the verification sections) to instruct checking
for the `_pos.png` packed-normal image and the `-remap_info.json` remapping
metadata instead. Ensure all occurrences noted (including the similar blocks at
the other ranges mentioned) are changed consistently.

In `@tools/godot-vat-test/scripts/VATPlayer.gd`:
- Around line 115-168: The _load_bake() parser still expects legacy sidecar keys
(frameCount, bounds, posTexture, optional nrmTexture) which breaks OpenVAT
bakes; update _load_bake() to detect and support the OpenVAT contract by: first
checking for an OpenVAT remap sidecar (e.g. filename containing
"-remap_info.json") or for OpenVAT keys ("Min","Max","Frames") and if present
parse those fields (map Min->bounds.min, Max->bounds.max, Frames->frameCount or
set _frame_count from "Frames"), treat normals as packed into the position
texture (so do not require nrmTexture and drop use of nrmTexture when OpenVAT is
detected), and keep backward compatibility by falling back to the legacy keys
(frameCount, bounds, posTexture, nrmTexture) when OpenVAT keys/sidecar are
absent; update the required-keys check and the pos_texture resolution logic in
_load_bake() accordingly so both formats load correctly.
- Around line 330-350: The v coordinate is computed against the full texture
height, so positions and normals read from the two packed halves collide; change
vertex() to compute separate v_pos and v_nrm that map into the top/bottom halves
of a texture with height = 2 * frame_count (e.g. v_pos = (current_frame + 0.5) /
(2.0 * frame_count) and v_nrm = v_pos + 0.5), then call sample_pos(global_vid,
v_pos) for VERTEX and use v_nrm when sampling nrm_tex for NORMAL (keep the
existing u, negation and normalize logic). Ensure you update uses of v to
v_pos/v_nrm in sample_pos and texture(nrm_tex, ...) respectively.

In `@tools/unity-vat-test/Assets/VAT/Editor/VATTestSetup.cs`:
- Around line 68-83: The file paths returned by Directory.GetFiles use
backslashes on Windows which break AssetDatabase.LoadAssetAtPath; before calling
AssetDatabase.LoadAssetAtPath for posTexPath, nrmTexPath, sidecarPath and
gltfPath, normalize each path by replacing '\' with '/' (e.g. posTexPath =
posTexPath?.Replace('\\','/')), then call AssetDatabase.LoadAssetAtPath as
before; after loading, add explicit null checks for posTex and sidecarTxt
(similar to the existing gltfPrefab check) and show an
EditorUtility.DisplayDialog / return on failure so failures aren’t silent.

In `@tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs`:
- Around line 140-142: The code assumes _sidecar.bounds.min and .max are
non-null arrays of length >=3 before indexing; add a guard in VATPlayer (around
where boundsMin and boundsMax are constructed) that validates _sidecar != null,
_sidecar.bounds != null, and that both _sidecar.bounds.min and
_sidecar.bounds.max are non-null and have Length (or Count) >= 3; if the checks
fail, log or throw a clear, fast-fail error mentioning VATPlayer and the
malformed sidecar (and return/abort initialization) so you never index into
missing/short arrays when creating boundsMin and boundsMax.
- Around line 44-56: The VATSidecar class models a legacy sidecar; update it to
match OpenVAT os-remap schema by replacing frameCount with Frames (int),
removing nrmTexture (normals are packed into posTexture), replacing
bounds/VATSidecarBounds with Min and Max (Vector3 or float[3]) fields to
represent remap bounds, and remove or adapt vertexCount if no longer present in
the new schema; also update any parsing/usage sites that read
VATSidecar.frameCount, VATSidecar.vertexCount, VATSidecar.bounds.*, and
VATSidecar.nrmTexture (including the parser around the other usages noted) to
read VATSidecar.Frames and VATSidecar.Min/Max and to use posTexture for normals.

In `@tools/unity-vat-test/bake_and_stage.sh`:
- Around line 5-7: Update the stale OpenVAT references: replace the comment and
any user-facing output that mentions the old "--target unity" with the new
OpenVAT-only wording, and change any reference to "sidecar.json" to the current
sidecar filename used by OpenVAT; specifically search for the exact strings
"--target unity" and "sidecar.json" in bake_and_stage.sh and update the comment
that describes the staging location (the string
"tools/unity-vat-test/Assets/VAT/Bakes/<basename_anim>/") and any echo/log calls
so they reflect the OpenVAT-only behavior and the correct sidecar filename.
- Around line 31-35: In the while/case parsing block in bake_and_stage.sh, guard
the --fps branch so it validates that a value exists before reading "$2" (to
avoid set -u failing); check that "$2" is present and not another option (e.g.,
starts with '-') and if invalid print a clear usage/error message and exit
non‑zero, otherwise assign FPS="$2" and shift 2 as currently done; update the
case handling for --fps accordingly to perform this validation and error
reporting.

In `@tools/unity-vat-test/Packages/manifest.json`:
- Around line 2-5: Add the official glTFast importer to the Unity package
manifest so .gltf assets can be imported and VATTestSetup can load source.gltf;
update the "dependencies" object in manifest.json to include the package key
"com.unity.cloud.gltfast" with version "6.10.3". Ensure the entry is added
alongside the existing keys ("com.unity.modules.unitywebrequest" and
"com.unity.collab-proxy") inside the same dependencies object and save the
manifest so Unity will restore the package before running VATTestSetup.

In `@tools/unity-vat-test/ProjectSettings/ProjectVersion.txt`:
- Line 2: Replace the placeholder text in the ProjectVersion.txt entry by
updating the m_EditorVersionWithRevision value to include the actual Unity
changeset hash in parentheses (format: "<version> (<changeset-hash>)"); locate
the m_EditorVersionWithRevision line in ProjectVersion.txt and change
"2022.3.40f1 (placeholder — Unity Hub will offer to upgrade on first open)" to
something like "2022.3.40f1 (a1b2c3d4e5f6)" using the real revision hash for
this build so Unity Hub won't trigger unnecessary metadata updates.

In `@tools/unity-vat-test/README.md`:
- Around line 81-83: Remove the stale reference to the removed CLI flag
`--normals` in the README troubleshooting text that mentions the shader
`_HasNrmTex`; update the sentence (the block that currently reads "If you bake
without `--normals`, the shader falls back..." and the follow-up "Re-bake with
`--normals` for proper lighting") to instead state that normals are managed by
the OpenVAT contract (or direct the reader to the OpenVAT baking workflow) and
remove the instruction to re-bake with `--normals`, ensuring `_HasNrmTex`
behavior is still documented accurately.

---

Outside diff comments:
In `@src/MCPServer.cpp`:
- Around line 4262-4277: The code is embedding the bake_vat metadata as a
pretty-printed JSON string inside the response instead of returning structured
fields; update the handler to return the metadata as top-level JSON members.
Replace the current QJsonObject content -> QJsonDocument(...).toJson(QString)
usage by constructing a top-level QJsonObject (or reuse content) that sets "ok",
"texture" (result.posTexPath), "sidecar" (result.jsonPath), "frameCount"
(result.frameCount), "vertexCount" (result.vertexCount), "animation" (animName),
"fps" (fps) and "bounds" (with min/max using result.minBound/result.maxBound)
and pass that structured object into makeSuccessResult so clients receive
machine-readable fields instead of a pretty-printed text blob; keep references
to makeSuccessResult, content, result, animName and fps to locate the change.

In `@src/VATBaker_test.cpp`:
- Around line 95-98: In VATBakerEndToEndTest::SetUp() add an assertion for
mesh-file availability: after the existing ASSERT_TRUE(tryInitOgre()) << "Ogre
init required"; call ASSERT_TRUE(canLoadMeshFiles()) << "Mesh files required";
so the fixture fails loudly when mesh assets are missing; update the SetUp
override to call canLoadMeshFiles() alongside tryInitOgre().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 069a7873-eeff-49c3-8491-055a3e0ffe98

📥 Commits

Reviewing files that changed from the base of the PR and between a1923f9 and 14e6dea.

⛔ Files ignored due to path filters (1)
  • tools/unity-vat-test/Assets/VAT/Shaders/VAT.shader is excluded by !**/*.shader
📒 Files selected for processing (23)
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp
  • src/VATBaker.cpp
  • src/VATBaker.h
  • src/VATBakerController.cpp
  • src/VATBakerController.h
  • src/VATBakerController_test.cpp
  • src/VATBaker_test.cpp
  • tools/godot-vat-test/.gitignore
  • tools/godot-vat-test/README.md
  • tools/godot-vat-test/bake_and_stage.sh
  • tools/godot-vat-test/project.godot
  • tools/godot-vat-test/scenes/Main.tscn
  • tools/godot-vat-test/scripts/Main.gd
  • tools/godot-vat-test/scripts/SkeletalLoader.gd
  • tools/godot-vat-test/scripts/VATPlayer.gd
  • tools/unity-vat-test/.gitignore
  • tools/unity-vat-test/Assets/VAT/Editor/VATTestSetup.cs
  • tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs
  • tools/unity-vat-test/Packages/manifest.json
  • tools/unity-vat-test/ProjectSettings/ProjectVersion.txt
  • tools/unity-vat-test/README.md
  • tools/unity-vat-test/bake_and_stage.sh

Comment thread src/VATBaker_test.cpp Outdated
Comment thread src/VATBaker_test.cpp
Comment thread tools/godot-vat-test/bake_and_stage.sh
Comment thread tools/godot-vat-test/README.md Outdated
Comment thread tools/godot-vat-test/README.md Outdated
Comment thread tools/unity-vat-test/bake_and_stage.sh Outdated
Comment thread tools/unity-vat-test/bake_and_stage.sh
Comment thread tools/unity-vat-test/Packages/manifest.json
Comment thread tools/unity-vat-test/ProjectSettings/ProjectVersion.txt Outdated
Comment thread tools/unity-vat-test/README.md Outdated
fernandotonon and others added 2 commits May 19, 2026 10:28
BREAKING: the `qtmesh vat` CLI subcommand, the `bake_vat` MCP tool, and
the `VATBakerController::bake()` Qt invokable all dropped their
`--encoding`, `--target`, and `--normals` flags. There is only one VAT
output format now: OpenVAT (https://github.com/sharpen3d/openvat). No
prior consumers exist outside this repo — VAT has not yet been released.

## Why

The pre-existing `agnostic` / `unity` / `unreal` / `godot` targets were
all variations of "QtMeshEditor's own bake," tailored to per-engine
axis conventions and sidecar shapes. None of them produced output that
dropped into a stock engine project — each was paired with our own
hand-written harness shader. Five targets where really one was needed.

OpenVAT is the cross-engine community convention: a Blender add-on
bakes, and reference shaders for Godot / Unity / Unreal / Blender all
consume the same `os-remap` JSON + packed-normals 16-bit RGB PNG. Real
downstream consumers exist; we should emit what they read.

## What changes

`VATBaker::Options` loses `target`, `encoding`, `bakeNormals`. The
only knobs left are `animationName`, `fps`, `startTime`, `endTime`,
`outputDir`, `basename`.

`VATBaker::BakeResult` loses `nrmTexPath` (normals are inside
`posTexPath` now), `unityMetaPath`, `godotShaderPath`.

`VATBaker::Target` and `VATBaker::Encoding` enums deleted. So are the
public encode/decode helpers (`encodeRGBA8`/`decodeRGBA8`/etc.) and
the `swizzleForTarget` / `targetFlipsRowsAtWriteTime` /
`buildUnityMeta` / `buildGodotShader` internal helpers.

Output is now:

  - `<basename>_pos.png` — 16-bit-per-channel PNG, width = vertexCount,
    height = 2 × frameCount. Rows [0..frameCount) hold positions
    normalized to the bake's bounds; rows [frameCount..2×frameCount)
    hold unit normals as `(n+1)/2`.
  - `<basename>-remap_info.json` — canonical OpenVAT sidecar:

    {
      "os-remap": {
        "Min":   ["-1.30000000","-0.10000000","-0.70000000"],
        "Max":   [ "0.70000000", "2.00000000", "0.40000000"],
        "Frames": 71
      },
      "_origin": "ogre-y-up-rh"
    }

    `Min`/`Max` are stringified 8-decimal-place floats with bounds
    rounded outward to the nearest 0.1 (matches the Blender add-on's
    `CustomEncoder` + `round_to_nearest_ten`). `_origin` is an
    extension key — openvat consumer shaders ignore unknown fields,
    but our own tooling uses it to know the source coordinate space.

The mesh must expose per-vertex normals; bakes without them fail
loudly rather than emitting half-populated textures.

## Caller fan-out

  - CLI:        `qtmesh vat <file> --anim <name> [--fps N] [-o <dir>] [--json]`
  - MCP tool:   `bake_vat` schema loses `encoding`, `target`, `normals`
  - Inspector:  `VATBakerController::bake(anim, fps, outputDir, basename)`
                — 4 args, was 7

Test harnesses (`tools/godot-vat-test/`, `tools/unity-vat-test/`)
keep their old bake_and_stage.sh wiring with the legacy CLI flags
removed; their runtime shaders (Godot `VATPlayer.gd`, Unity
`VAT.shader`) still target the pre-OpenVAT layout and will need a
follow-up rewrite to consume the packed-normals texture + os-remap
sidecar before the harnesses run again. Not blocking real users —
shipped output is now canonical OpenVAT, and any consumer with a
working openvat shader will read it directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

P1 fix (correctness, decode drift)
----------------------------------
The texture used to be encoded against the raw `lo`/`hi` min/max while
the sidecar published outward-rounded bounds (e.g. -0.616 → -0.700).
A consumer decoding through the sidecar would have systematic drift of
up to one rounding step (~0.05 per axis on a 1-unit model) on every
sample. Now bake() snaps both to `roundedLo`/`roundedHi` once and uses
them everywhere — texture encoder, sidecar emitter, and BakeResult.bounds
all agree to the bit. New test `OpenVATBoundsRoundedOutwardToTenth`
asserts the sidecar Min/Max equal BakeResult.minBound/maxBound exactly.

Sidecar extension key — replaced the made-up `_origin` field with two
non-conflicting underscore-prefixed keys that openvat consumers ignore:
  - `_producer: "QtMeshEditor"` — tool identifier
  - `_axes: "y-up-rh"` — source coordinate convention so consumer
    shaders know what swizzle (if any) to apply on read

Inspector UI move (per user ask)
--------------------------------
Bake VAT controls moved from the Scene tab's Animations subgroup into
a dedicated "VAT" CollapsibleSection in Mode Tools, surfaced when
AnimationMode is active. New `VATBakerController::chooseOutputDir()`
opens a Qt-rendered (non-native) folder picker — matches the
`MaterialEditorQML::openFileDialog` pattern that reliably opens from
inside a QQuickWidget on macOS.

Harness rewrites — OpenVAT-aware Godot + Unity
----------------------------------------------
Godot `VATPlayer.gd`:
- Reads `<basename>-remap_info.json` instead of the legacy QtMeshEditor
  v1 sidecar.
- Parses `os-remap.{Min, Max, Frames}` (stringified arrays).
- Embedded shader samples one packed texture at two V coordinates:
  `v_pos = (frame + 0.5) / (2*frames)` and `v_nrm = v_pos + 0.5`.
- `fps_override` exposes playback rate (OpenVAT sidecar carries no fps).

Unity `VATPlayer.cs` + `VAT.shader` + `VATTestSetup.cs`:
- Same os-remap parser (small manual JSON walk — JsonUtility can't
  handle hyphens in keys).
- Shader updated to packed-normals layout, drops the `_NrmTex` /
  `_HasNrmTex` properties.
- Editor scene builder looks for `*-remap_info.json` + `*_pos.png`,
  drops normal-map references.
- Path normalization (`\` → `/`) so AssetDatabase.LoadAssetAtPath works
  on Windows (Directory.GetFiles returns backslash paths there).

Other review fixups
-------------------
- `--fps` arity guard in both bake_and_stage.sh scripts — explicit
  error when the flag is passed without a value (set -u was bailing
  with a confusing shell error).
- Test `ProducesDistinctRowsAcrossFrames` now compares raw scanline
  bytes via `memcmp` instead of `QImage::pixel()` — pixel() truncates
  16-bit RGBX64 to 8-bit QRgb which could mask sub-byte motion.
- Test `RejectsMissingAnimationOnLiveEntity` swapped hardcoded /tmp
  for QTemporaryDir.
- README cleanups in both harnesses — removed stale `--target`,
  `--encoding`, `--normals`, `<name>.gdshader`, `<name>_nrm.png`
  references.
- Unity `Packages/manifest.json` adds `com.unity.cloud.gltfast` so
  `.gltf` imports work out-of-the-box (Unity 2022.3 has no built-in
  glTF importer).
- Unity `ProjectVersion.txt` swaps the placeholder comment for a real
  revision hash so Unity Hub doesn't complain.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
src/VATBakerController.cpp (1)

188-207: ⚡ Quick win

Scope the non-native dialog workaround to macOS.

The comment documents a macOS-specific issue, but the implementation disables native dialogs on every platform. That regresses the picker UX on Windows/Linux for a platform-local workaround.

Proposed fix
-    const QString chosen = QFileDialog::getExistingDirectory(
+    QFileDialog::Options options = QFileDialog::ShowDirsOnly
+        | QFileDialog::DontUseCustomDirectoryIcons;
+#ifdef Q_OS_MACOS
+    options |= QFileDialog::DontUseNativeDialog;
+#endif
+
+    const QString chosen = QFileDialog::getExistingDirectory(
         parent,
         QStringLiteral("Choose OpenVAT output folder"),
         seed,
-        QFileDialog::ShowDirsOnly
-            | QFileDialog::DontUseNativeDialog
-            | QFileDialog::DontUseCustomDirectoryIcons);
+        options);

As per coding guidelines, src/**/*.{cpp,h}: Guard all platform-specific APIs with #ifdefQ_OS_WIN,#ifdefQ_OS_MACOS, or#ifdef Q_OS_LINUX preprocessor directives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/VATBakerController.cpp` around lines 188 - 207, The code currently forces
QFileDialog::DontUseNativeDialog for all platforms; restrict this macOS-only
workaround by conditionally adding DontUseNativeDialog when building for macOS.
Modify the call that builds the flags for QFileDialog::getExistingDirectory
(around the QFileDialog::ShowDirsOnly | ... expression) to include
QFileDialog::DontUseNativeDialog only inside an `#ifdef` Q_OS_MACOS / `#endif` block
(leave QApplication::processEvents(), parent->raise()/activateWindow() and other
behavior unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@qml/PropertiesPanel.qml`:
- Around line 699-760: The Browse and Bake controls are Rectangle+MouseArea
(animBrowseMa, animBakeMa) and lack keyboard focus/activation; make them
keyboard-accessible by either replacing them with Qt Quick Controls Button or
adding activeFocusOnTab: true to the Rectangle, giving the MouseArea focus: true
and keys handlers (e.g., keys.onPressed to trigger onClicked for Enter/Space),
and adding accessibility metadata (accessible.name/accessible.role) so screen
readers can announce them; ensure enabled state (used in cursorShape and
opacity) is respected in key handlers and that the click logic on animBrowseMa
and VATBakerController.bake(...) is invoked from the keyboard handlers as well.

In `@src/VATBaker.cpp`:
- Around line 551-558: Check the return value of jf.write(sidecar.toUtf8()) and
handle failures: after attempting the write in the block that opens QFile jf
(using result.jsonPath), verify the number of bytes written matches
sidecar.toUtf8().size() (or that write() did not return -1), set result.error to
a descriptive message and return result on failure, and only set result.ok =
true after a successful full write and jf.close(); ensure references are to jf,
jf.write(...), sidecar.toUtf8(), result.jsonPath and result.ok.
- Around line 513-557: Add Sentry breadcrumbing for the OpenVAT export steps:
call SentryReporter::addBreadcrumb("file.export", ...) before the directory
creation (around QDir().mkpath(opts.outputDir)), before saving the image (around
img.save(result.posTexPath, "PNG")) and before writing the sidecar (around
jf.open(...) / jf.write(...)); include the path (result.posTexPath or
result.jsonPath) in each message so Sentry shows which file operation was
attempted. Ensure calls use the "file.export" category and descriptive messages
like "create output dir: <outputDir>", "write pos texture: <posTexPath>", and
"write remap sidecar: <jsonPath>".

In `@src/VATBakerController.cpp`:
- Around line 180-208: In chooseOutputDir, add Sentry breadcrumbs via
SentryReporter::addBreadcrumb("ui.action", ...) to record when the folder picker
is opened and when it is accepted or cancelled; call addBreadcrumb just before
calling QFileDialog::getExistingDirectory to log the "open" action, then after
the call inspect the returned QString chosen and call addBreadcrumb with an
"accept" message including the chosen path when non-empty or a "cancel" message
when empty so UI actions are traceable in Sentry.
- Around line 145-147: Change the Sentry breadcrumb category from "ui.action" to
"file.export" for the VAT bake start/end breadcrumbs: update the
SentryReporter::addBreadcrumb calls that log "OpenVAT bake start: anim=%1
fps=%2" (and the corresponding bake end message) to use "file.export" and
include the bake output path/identifier alongside animationName and fps so the
breadcrumb clearly reflects an export operation.

In `@tools/godot-vat-test/scripts/VATPlayer.gd`:
- Around line 241-242: Clamp loop_frames to not exceed _frame_count before using
it to advance _current_frame: replace the loop_count computation in VATPlayer.gd
(the var loop_count and subsequent _current_frame update) so loop_count =
(min(loop_frames, _frame_count) if loop_frames > 0 else _frame_count) (or use
clamp(loop_frames, 1, _frame_count) style) so _current_frame =
fposmod(_current_frame + delta * fps_override, float(loop_count)) never wraps
past the baked frame rows.
- Around line 110-116: Currently _load_bake() picks the first "-remap_info.json"
and the first "_pos.png" independently which can mix files from different bakes;
instead, when you find the selected json_path (variable json_path from the loop)
derive the matching pos_path by using the same base name: replace the
"-remap_info.json" suffix with "_pos.png" (or search for a file whose name
starts with the json's base and ends with "_pos.png") and set pos_path to that
match; keep the loop but only set pos_path when its basename matches json_path's
basename so json_path and pos_path always come from the same bake.

In `@tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs`:
- Around line 101-103: loopFrames can exceed the baked frame count causing
_currentFrame to index beyond valid rows; fix by clamping the computed loop
value to _frameCount before using it for modulus. Replace the current loop
calculation with one that sets loop = Mathf.Min(loopFrames > 0 ? loopFrames :
_frameCount, _frameCount) (or equivalent), and use that clamped loop when
computing _currentFrame = (_currentFrame + Time.deltaTime * fpsOverride) % loop
so _currentFrame never rolls past the baked frames; update references to
loopFrames/_currentFrame in VATPlayer accordingly.
- Around line 215-222: Replace the brittle float.Parse calls used to populate
_boundsMin and _boundsMax from parsed.Min/parsed.Max with defensive parsing
using float.TryParse (with System.Globalization.CultureInfo.InvariantCulture)
for each component; if any TryParse fails, log a clear error that includes the
offending string(s) and the context (e.g., which bound/component failed) and
return false instead of letting an exception bubble up—update the code paths
around the parsing in VATPlayer (the block that assigns _boundsMin/_boundsMax)
to perform these checks for all 6 values before assigning.

---

Nitpick comments:
In `@src/VATBakerController.cpp`:
- Around line 188-207: The code currently forces
QFileDialog::DontUseNativeDialog for all platforms; restrict this macOS-only
workaround by conditionally adding DontUseNativeDialog when building for macOS.
Modify the call that builds the flags for QFileDialog::getExistingDirectory
(around the QFileDialog::ShowDirsOnly | ... expression) to include
QFileDialog::DontUseNativeDialog only inside an `#ifdef` Q_OS_MACOS / `#endif` block
(leave QApplication::processEvents(), parent->raise()/activateWindow() and other
behavior unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fcb2fd68-6071-43b4-b16a-7b68610d007e

📥 Commits

Reviewing files that changed from the base of the PR and between 14e6dea and 5e6b641.

⛔ Files ignored due to path filters (1)
  • tools/unity-vat-test/Assets/VAT/Shaders/VAT.shader is excluded by !**/*.shader
📒 Files selected for processing (18)
  • qml/PropertiesPanel.qml
  • src/CLIPipeline.cpp
  • src/MCPServer.cpp
  • src/VATBaker.cpp
  • src/VATBaker.h
  • src/VATBakerController.cpp
  • src/VATBakerController.h
  • src/VATBakerController_test.cpp
  • src/VATBaker_test.cpp
  • tools/godot-vat-test/README.md
  • tools/godot-vat-test/bake_and_stage.sh
  • tools/godot-vat-test/scripts/VATPlayer.gd
  • tools/unity-vat-test/Assets/VAT/Editor/VATTestSetup.cs
  • tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs
  • tools/unity-vat-test/Packages/manifest.json
  • tools/unity-vat-test/ProjectSettings/ProjectVersion.txt
  • tools/unity-vat-test/README.md
  • tools/unity-vat-test/bake_and_stage.sh
✅ Files skipped from review due to trivial changes (3)
  • tools/unity-vat-test/ProjectSettings/ProjectVersion.txt
  • tools/unity-vat-test/README.md
  • tools/godot-vat-test/README.md
🚧 Files skipped from review as they are similar to previous changes (7)
  • tools/godot-vat-test/bake_and_stage.sh
  • tools/unity-vat-test/Packages/manifest.json
  • src/MCPServer.cpp
  • src/VATBakerController_test.cpp
  • src/CLIPipeline.cpp
  • src/VATBaker.h
  • src/VATBaker_test.cpp

Comment thread qml/PropertiesPanel.qml
Comment on lines +699 to +760
Rectangle {
width: 60; height: 24; radius: 3
anchors.verticalCenter: parent.verticalCenter
color: animBrowseMa.pressed
? Qt.darker(PropertiesPanelController.headerColor, 1.2)
: animBrowseMa.containsMouse
? Qt.lighter(PropertiesPanelController.headerColor, 1.2)
: PropertiesPanelController.headerColor
border.color: PropertiesPanelController.borderColor
Text {
anchors.centerIn: parent
text: "Browse"
color: PropertiesPanelController.textColor; font.pixelSize: 10
}
MouseArea {
id: animBrowseMa
anchors.fill: parent
hoverEnabled: true
enabled: !VATBakerController.isBaking
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: {
const picked = VATBakerController.chooseOutputDir(animToolsCol.outputDir)
if (picked && picked.length > 0)
animToolsCol.outputDir = picked
}
}
}
}

// Bake button — same look as Animation Control's play button
// (lighten/darken on the header color; no highlight ramp).
Rectangle {
width: parent.width - 16; height: 26; radius: 3
color: animBakeMa.pressed
? Qt.darker(PropertiesPanelController.headerColor, 1.2)
: animBakeMa.containsMouse && animBakeMa.enabled
? Qt.lighter(PropertiesPanelController.headerColor, 1.2)
: PropertiesPanelController.headerColor
opacity: animBakeMa.enabled ? 1.0 : 0.45
border.color: PropertiesPanelController.borderColor

Text {
anchors.centerIn: parent
text: VATBakerController.isBaking ? "Baking…" : "Bake VAT"
color: PropertiesPanelController.textColor; font.pixelSize: 11
}
MouseArea {
id: animBakeMa
anchors.fill: parent
hoverEnabled: true
enabled: !VATBakerController.isBaking
&& animToolsCol.animName !== ""
&& animToolsCol.outputDir !== ""
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: {
VATBakerController.bake(
animToolsCol.animName,
animToolsCol.fps,
animToolsCol.outputDir,
"")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 94


🏁 Script executed:

wc -l qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 99


🏁 Script executed:

sed -n '699,760p' qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 3164


🏁 Script executed:

grep -n "Button" qml/PropertiesPanel.qml | head -20

Repository: fernandotonon/QtMeshEditor

Length of output: 771


🏁 Script executed:

grep -n "activeFocusOnTab\|KeyNavigation\|Keys.onPressed\|Accessible" qml/PropertiesPanel.qml | head -20

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

sed -n '680,800p' qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 5907


🏁 Script executed:

sed -n '1990,2020p' qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 1116


🏁 Script executed:

sed -n '3980,4000p' qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 964


🏁 Script executed:

sed -n '650,700p' qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 2104


🏁 Script executed:

sed -n '699,760p' qml/PropertiesPanel.qml | grep -i "keys\|focus\|accessible"

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

rg "Keys\.on|activeFocusOnTab|Accessible\." qml/PropertiesPanel.qml -A 2 -B 2 | head -40

Repository: fernandotonon/QtMeshEditor

Length of output: 1231


🏁 Script executed:

sed -n '620,780p' qml/PropertiesPanel.qml | cat -n

Repository: fernandotonon/QtMeshEditor

Length of output: 8038


🏁 Script executed:

rg "Rectangle.*MouseArea" qml/PropertiesPanel.qml | head -20

Repository: fernandotonon/QtMeshEditor

Length of output: 52


🏁 Script executed:

sed -n '3980,4010p' qml/PropertiesPanel.qml

Repository: fernandotonon/QtMeshEditor

Length of output: 1229


Make the Browse and Bake controls keyboard-accessible.

Both controls are Rectangle + MouseArea with no tab focus or key handling, creating a keyboard navigation gap in the VAT bake flow. The animation picker (ComboBox), FPS (SpinBox), and output directory (TextField) all support keyboard interaction, but Browse and Bake do not. A keyboard-only user cannot complete the workflow from this panel. Prefer Button or add activeFocusOnTab, Enter/Space handlers, and accessibility metadata to maintain consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qml/PropertiesPanel.qml` around lines 699 - 760, The Browse and Bake controls
are Rectangle+MouseArea (animBrowseMa, animBakeMa) and lack keyboard
focus/activation; make them keyboard-accessible by either replacing them with Qt
Quick Controls Button or adding activeFocusOnTab: true to the Rectangle, giving
the MouseArea focus: true and keys handlers (e.g., keys.onPressed to trigger
onClicked for Enter/Space), and adding accessibility metadata
(accessible.name/accessible.role) so screen readers can announce them; ensure
enabled state (used in cursorShape and opacity) is respected in key handlers and
that the click logic on animBrowseMa and VATBakerController.bake(...) is invoked
from the keyboard handlers as well.

Comment thread src/VATBaker.cpp Outdated
Comment on lines 513 to 557
QDir().mkpath(opts.outputDir);
const QString base = opts.basename.isEmpty() ? opts.animationName : opts.basename;
result.posTexPath = QDir(opts.outputDir).filePath(base + "_pos.png");
result.jsonPath = QDir(opts.outputDir).filePath(base + ".json");
result.jsonPath = QDir(opts.outputDir).filePath(base + "-remap_info.json");

const bool flipRows = targetFlipsRowsAtWriteTime(opts.target);

// Encode + write the position texture. PNG preserves both 8-bit
// and 16-bit channel depths; QImage handles the format switch for us.
if (opts.encoding == Encoding::RGBA8) {
auto rgba = encodeRGBA8(flat, frameCount, vertexCount, lo, hi);
if (rgba.empty()) {
result.error = QStringLiteral("encodeRGBA8 produced empty buffer");
return result;
}
QImage img(vertexCount, frameCount, QImage::Format_RGBA8888);
for (int y = 0; y < frameCount; ++y) {
const int srcRow = flipRows ? (frameCount - 1 - y) : y;
const unsigned char* src = rgba.data() + static_cast<size_t>(srcRow)
* static_cast<size_t>(vertexCount) * 4u;
std::memcpy(img.scanLine(y), src, static_cast<size_t>(vertexCount) * 4u);
}
if (!img.save(result.posTexPath, "PNG")) {
result.error = QStringLiteral("failed to write position texture: %1")
.arg(result.posTexPath);
return result;
}
} else { // RGBA16
auto rgba = encodeRGBA16(flat, frameCount, vertexCount, lo, hi);
if (rgba.empty()) {
result.error = QStringLiteral("encodeRGBA16 produced empty buffer");
return result;
}
// Format_RGBA64 is 16 bits per channel — Qt's `save("PNG")` writes
// a 16-bit PNG which Godot/Unity/Unreal all read losslessly.
QImage img(vertexCount, frameCount, QImage::Format_RGBA64);
for (int y = 0; y < frameCount; ++y) {
const int srcRow = flipRows ? (frameCount - 1 - y) : y;
const uint16_t* src = rgba.data() + static_cast<size_t>(srcRow)
* static_cast<size_t>(vertexCount) * 4u;
std::memcpy(img.scanLine(y), src, static_cast<size_t>(vertexCount) * 4u * sizeof(uint16_t));
}
if (!img.save(result.posTexPath, "PNG")) {
result.error = QStringLiteral("failed to write 16-bit position texture: %1")
.arg(result.posTexPath);
return result;
}
auto packed = packOpenVAT16(flat, normals, frameCount, vertexCount,
roundedLo, roundedHi);
if (packed.empty()) {
result.error = QStringLiteral("OpenVAT pack produced empty buffer");
return result;
}

// Normal texture — only when requested. Same layout / size, normals
// mapped from [-1, 1] → [0, MAX] per channel.
if (opts.bakeNormals) {
result.nrmTexPath = QDir(opts.outputDir).filePath(base + "_nrm.png");
if (opts.encoding == Encoding::RGBA8) {
auto rgba = encodeNormalsRGBA8(normals, frameCount, vertexCount);
if (rgba.empty()) {
result.error = QStringLiteral("encodeNormalsRGBA8 produced empty buffer");
return result;
}
QImage img(vertexCount, frameCount, QImage::Format_RGBA8888);
for (int y = 0; y < frameCount; ++y) {
const int srcRow = flipRows ? (frameCount - 1 - y) : y;
const unsigned char* src = rgba.data() + static_cast<size_t>(srcRow)
* static_cast<size_t>(vertexCount) * 4u;
std::memcpy(img.scanLine(y), src, static_cast<size_t>(vertexCount) * 4u);
}
if (!img.save(result.nrmTexPath, "PNG")) {
result.error = QStringLiteral("failed to write normal texture: %1")
.arg(result.nrmTexPath);
return result;
}
} else { // RGBA16
auto rgba = encodeNormalsRGBA16(normals, frameCount, vertexCount);
if (rgba.empty()) {
result.error = QStringLiteral("encodeNormalsRGBA16 produced empty buffer");
return result;
}
QImage img(vertexCount, frameCount, QImage::Format_RGBA64);
for (int y = 0; y < frameCount; ++y) {
const int srcRow = flipRows ? (frameCount - 1 - y) : y;
const uint16_t* src = rgba.data() + static_cast<size_t>(srcRow)
* static_cast<size_t>(vertexCount) * 4u;
std::memcpy(img.scanLine(y), src, static_cast<size_t>(vertexCount) * 4u * sizeof(uint16_t));
}
if (!img.save(result.nrmTexPath, "PNG")) {
result.error = QStringLiteral("failed to write 16-bit normal texture: %1")
.arg(result.nrmTexPath);
return result;
}
const int imgHeight = frameCount * 2;
// RGBX64 is Qt's 16-bit-per-channel 4-channel format. The X channel
// is padding; PNG can store 3-channel data losslessly but Qt's PNG
// writer infers RGB-vs-RGBA from the QImage format, and Format_RGB
// doesn't exist at 16-bit precision. Padding to RGBX64 costs a few
// hundred KB on a 5828×142 image — acceptable for a one-off bake.
QImage img(vertexCount, imgHeight, QImage::Format_RGBX64);
img.fill(0);
for (int y = 0; y < imgHeight; ++y) {
const uint16_t* src = packed.data()
+ static_cast<size_t>(y)
* static_cast<size_t>(vertexCount) * 3u;
auto* dst = reinterpret_cast<uint16_t*>(img.scanLine(y));
for (int x = 0; x < vertexCount; ++x) {
dst[x * 4 + 0] = src[x * 3 + 0];
dst[x * 4 + 1] = src[x * 3 + 1];
dst[x * 4 + 2] = src[x * 3 + 2];
dst[x * 4 + 3] = 65535;
}
}
if (!img.save(result.posTexPath, "PNG")) {
result.error = QStringLiteral("failed to write OpenVAT texture: %1")
.arg(result.posTexPath);
return result;
}

// Sidecar.
const QString sidecar = buildSidecarJson(result, opts);
const QString sidecar = buildOpenVATSidecar(frameCount, roundedLo, roundedHi);
QFile jf(result.jsonPath);
if (!jf.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
result.error = QStringLiteral("failed to open JSON for write: %1")
result.error = QStringLiteral("failed to open OpenVAT sidecar for write: %1")
.arg(result.jsonPath);
return result;
}
jf.write(sidecar.toUtf8());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add file.export breadcrumbs around the OpenVAT writes.

This path creates the export directory and writes both output artifacts, but none of those operations are breadcrumbed. That makes export failures much harder to reconstruct in Sentry.

Suggested change
+    SentryReporter::addBreadcrumb(
+        QStringLiteral("file.export"),
+        QStringLiteral("Preparing OpenVAT export in %1").arg(opts.outputDir));
     QDir().mkpath(opts.outputDir);
@@
+    SentryReporter::addBreadcrumb(
+        QStringLiteral("file.export"),
+        QStringLiteral("Writing OpenVAT texture to %1").arg(result.posTexPath));
     if (!img.save(result.posTexPath, "PNG")) {
@@
+    SentryReporter::addBreadcrumb(
+        QStringLiteral("file.export"),
+        QStringLiteral("Writing OpenVAT sidecar to %1").arg(result.jsonPath));
     QFile jf(result.jsonPath);

As per coding guidelines, "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".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/VATBaker.cpp` around lines 513 - 557, Add Sentry breadcrumbing for the
OpenVAT export steps: call SentryReporter::addBreadcrumb("file.export", ...)
before the directory creation (around QDir().mkpath(opts.outputDir)), before
saving the image (around img.save(result.posTexPath, "PNG")) and before writing
the sidecar (around jf.open(...) / jf.write(...)); include the path
(result.posTexPath or result.jsonPath) in each message so Sentry shows which
file operation was attempted. Ensure calls use the "file.export" category and
descriptive messages like "create output dir: <outputDir>", "write pos texture:
<posTexPath>", and "write remap sidecar: <jsonPath>".

Comment thread src/VATBaker.cpp
Comment on lines 551 to 558
QFile jf(result.jsonPath);
if (!jf.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
result.error = QStringLiteral("failed to open JSON for write: %1")
result.error = QStringLiteral("failed to open OpenVAT sidecar for write: %1")
.arg(result.jsonPath);
return result;
}
jf.write(sidecar.toUtf8());
jf.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Check the sidecar write result before reporting success.

QFile::write() is ignored here, so a short/failed write still falls through to result.ok = true and leaves a truncated -remap_info.json behind.

Suggested fix
     QFile jf(result.jsonPath);
     if (!jf.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
         result.error = QStringLiteral("failed to open OpenVAT sidecar for write: %1")
                            .arg(result.jsonPath);
         return result;
     }
-    jf.write(sidecar.toUtf8());
+    const QByteArray payload = sidecar.toUtf8();
+    if (jf.write(payload) != payload.size() || !jf.flush()) {
+        result.error = QStringLiteral("failed to write OpenVAT sidecar: %1")
+                           .arg(jf.errorString());
+        jf.close();
+        return result;
+    }
     jf.close();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/VATBaker.cpp` around lines 551 - 558, Check the return value of
jf.write(sidecar.toUtf8()) and handle failures: after attempting the write in
the block that opens QFile jf (using result.jsonPath), verify the number of
bytes written matches sidecar.toUtf8().size() (or that write() did not return
-1), set result.error to a descriptive message and return result on failure, and
only set result.ok = true after a successful full write and jf.close(); ensure
references are to jf, jf.write(...), sidecar.toUtf8(), result.jsonPath and
result.ok.

Comment thread src/VATBakerController.cpp Outdated
Comment on lines +145 to +147
SentryReporter::addBreadcrumb("ui.action",
QStringLiteral("VAT bake start: anim=%1 fps=%2 encoding=%3 target=%4 normals=%5")
.arg(animationName).arg(fps).arg(enc, tgt).arg(bakeNormals ? "yes" : "no"));
QStringLiteral("OpenVAT bake start: anim=%1 fps=%2")
.arg(animationName).arg(fps));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use file.export breadcrumbs for the bake path.

This is an export operation, so logging it as ui.action will mix file-output telemetry into click noise. Please switch the bake start/end breadcrumbs to file.export.

Proposed fix
-    SentryReporter::addBreadcrumb("ui.action",
+    SentryReporter::addBreadcrumb("file.export",
         QStringLiteral("OpenVAT bake start: anim=%1 fps=%2")
             .arg(animationName).arg(fps));
...
-    SentryReporter::addBreadcrumb("ui.action",
+    SentryReporter::addBreadcrumb("file.export",
         result.ok
             ? QStringLiteral("VAT bake ok: %1 frames × %2 vertices → %3")
                   .arg(result.frameCount).arg(result.vertexCount).arg(result.posTexPath)
             : QStringLiteral("VAT bake failed: %1").arg(result.error));

As per coding guidelines, src/**/*.cpp: 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.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/VATBakerController.cpp` around lines 145 - 147, Change the Sentry
breadcrumb category from "ui.action" to "file.export" for the VAT bake start/end
breadcrumbs: update the SentryReporter::addBreadcrumb calls that log "OpenVAT
bake start: anim=%1 fps=%2" (and the corresponding bake end message) to use
"file.export" and include the bake output path/identifier alongside
animationName and fps so the breadcrumb clearly reflects an export operation.

Comment on lines +180 to +208
QString VATBakerController::chooseOutputDir(const QString& startDir)
{
QString seed = startDir;
if (seed.isEmpty() || !QFileInfo(seed).isDir())
seed = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
if (seed.isEmpty())
seed = QDir::homePath();

// Mirror the MaterialEditorQML::openFileDialog dance: process pending
// events + raise the active window before opening the dialog, and
// force the Qt-rendered dialog rather than the native one. Native
// file dialogs hosted from inside a QQuickWidget have been observed
// to silently no-op on macOS — DontUseNativeDialog reliably opens.
QApplication::processEvents();
QWidget* parent = QApplication::activeWindow();
if (parent) {
parent->raise();
parent->activateWindow();
}
QApplication::processEvents();

const QString chosen = QFileDialog::getExistingDirectory(
parent,
QStringLiteral("Choose OpenVAT output folder"),
seed,
QFileDialog::ShowDirsOnly
| QFileDialog::DontUseNativeDialog
| QFileDialog::DontUseCustomDirectoryIcons);
return chosen;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Breadcrumb the folder-picker flow.

chooseOutputDir(...) is a new user-facing action, but it currently leaves no trace in Sentry. Add ui.action breadcrumbs for open + accept/cancel so VAT failures can be correlated with picker behavior.

Proposed fix
 QString VATBakerController::chooseOutputDir(const QString& startDir)
 {
     QString seed = startDir;
     if (seed.isEmpty() || !QFileInfo(seed).isDir())
         seed = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
     if (seed.isEmpty())
         seed = QDir::homePath();
+
+    SentryReporter::addBreadcrumb("ui.action",
+        "VAT output directory picker opened");
 
     // Mirror the MaterialEditorQML::openFileDialog dance: process pending
     // events + raise the active window before opening the dialog, and
@@
     const QString chosen = QFileDialog::getExistingDirectory(
         parent,
         QStringLiteral("Choose OpenVAT output folder"),
         seed,
         QFileDialog::ShowDirsOnly
             | QFileDialog::DontUseNativeDialog
             | QFileDialog::DontUseCustomDirectoryIcons);
+    SentryReporter::addBreadcrumb("ui.action",
+        chosen.isEmpty()
+            ? "VAT output directory picker cancelled"
+            : "VAT output directory picker accepted");
     return chosen;
 }

As per coding guidelines, src/**/*.cpp: 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.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/VATBakerController.cpp` around lines 180 - 208, In chooseOutputDir, add
Sentry breadcrumbs via SentryReporter::addBreadcrumb("ui.action", ...) to record
when the folder picker is opened and when it is accepted or cancelled; call
addBreadcrumb just before calling QFileDialog::getExistingDirectory to log the
"open" action, then after the call inspect the returned QString chosen and call
addBreadcrumb with an "accept" message including the chosen path when non-empty
or a "cancel" message when empty so UI actions are traceable in Sentry.

Comment on lines +110 to +116
var json_path := ""
var shader_path := ""
var pos_path := ""
for f in dir.get_files():
if f.ends_with(".json") and json_path.is_empty():
if f.ends_with("-remap_info.json") and json_path.is_empty():
json_path = bake_dir.path_join(f)
elif f.ends_with(".gdshader") and shader_path.is_empty():
shader_path = bake_dir.path_join(f)
elif f.ends_with("_pos.png") and pos_path.is_empty():
pos_path = bake_dir.path_join(f)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Match the PNG to the selected sidecar basename.

_load_bake() currently grabs the first *-remap_info.json and the first *_pos.png independently. If bake_dir contains more than one staged bake, this can pair metadata from one bake with texture data from another and silently corrupt playback.

Proposed fix
 var json_path := ""
 var pos_path := ""
+var bake_base := ""
 for f in dir.get_files():
 	if f.ends_with("-remap_info.json") and json_path.is_empty():
+		bake_base = f.trim_suffix("-remap_info.json")
 		json_path = bake_dir.path_join(f)
-	elif f.ends_with("_pos.png") and pos_path.is_empty():
-		pos_path = bake_dir.path_join(f)
+		pos_path = bake_dir.path_join("%s_pos.png" % bake_base)
 
 if json_path.is_empty():
 	push_error("VATPlayer: no *-remap_info.json sidecar in %s — " % bake_dir +
 		"is this an OpenVAT bake? Try rerunning bake_and_stage.sh.")
 	return false
-if pos_path.is_empty():
-	push_error("VATPlayer: no *_pos.png in %s" % bake_dir)
+if pos_path.is_empty() or not FileAccess.file_exists(pos_path):
+	push_error("VATPlayer: no matching *_pos.png in %s" % bake_dir)
 	return false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/godot-vat-test/scripts/VATPlayer.gd` around lines 110 - 116, Currently
_load_bake() picks the first "-remap_info.json" and the first "_pos.png"
independently which can mix files from different bakes; instead, when you find
the selected json_path (variable json_path from the loop) derive the matching
pos_path by using the same base name: replace the "-remap_info.json" suffix with
"_pos.png" (or search for a file whose name starts with the json's base and ends
with "_pos.png") and set pos_path to that match; keep the loop but only set
pos_path when its basename matches json_path's basename so json_path and
pos_path always come from the same bake.

Comment on lines +241 to +242
var loop_count: int = (loop_frames if loop_frames > 0 else _frame_count)
_current_frame = fposmod(_current_frame + delta * _fps, float(loop_count))
# Drive every per-surface material's current_frame uniformly.
_current_frame = fposmod(_current_frame + delta * fps_override, float(loop_count))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp loop_frames to _frame_count.

If loop_frames is set above the baked frame count, _current_frame eventually points past the position half of the packed texture, so the shader starts sampling the wrong rows.

Proposed fix
-	var loop_count: int = (loop_frames if loop_frames > 0 else _frame_count)
+	var loop_count: int = (_frame_count if loop_frames <= 0 else min(loop_frames, _frame_count))
 	_current_frame = fposmod(_current_frame + delta * fps_override, float(loop_count))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var loop_count: int = (loop_frames if loop_frames > 0 else _frame_count)
_current_frame = fposmod(_current_frame + delta * _fps, float(loop_count))
# Drive every per-surface material's current_frame uniformly.
_current_frame = fposmod(_current_frame + delta * fps_override, float(loop_count))
var loop_count: int = (_frame_count if loop_frames <= 0 else min(loop_frames, _frame_count))
_current_frame = fposmod(_current_frame + delta * fps_override, float(loop_count))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/godot-vat-test/scripts/VATPlayer.gd` around lines 241 - 242, Clamp
loop_frames to not exceed _frame_count before using it to advance
_current_frame: replace the loop_count computation in VATPlayer.gd (the var
loop_count and subsequent _current_frame update) so loop_count =
(min(loop_frames, _frame_count) if loop_frames > 0 else _frame_count) (or use
clamp(loop_frames, 1, _frame_count) style) so _current_frame =
fposmod(_current_frame + delta * fps_override, float(loop_count)) never wraps
past the baked frame rows.

Comment on lines 101 to 103
int loop = (loopFrames > 0) ? loopFrames : _frameCount;
_currentFrame = (_currentFrame + Time.deltaTime * fpsOverride) % loop;
for (int i = 0; i < _materials.Length; i++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp loopFrames to baked frame count.

If loopFrames is set larger than _frameCount, _CurrentFrame can step beyond valid baked rows and produce incorrect sampling.

Proposed fix
-        int loop = (loopFrames > 0) ? loopFrames : _frameCount;
+        int loop = (loopFrames > 0) ? Mathf.Min(loopFrames, _frameCount) : _frameCount;
         _currentFrame = (_currentFrame + Time.deltaTime * fpsOverride) % loop;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
int loop = (loopFrames > 0) ? loopFrames : _frameCount;
_currentFrame = (_currentFrame + Time.deltaTime * fpsOverride) % loop;
for (int i = 0; i < _materials.Length; i++) {
int loop = (loopFrames > 0) ? Mathf.Min(loopFrames, _frameCount) : _frameCount;
_currentFrame = (_currentFrame + Time.deltaTime * fpsOverride) % loop;
for (int i = 0; i < _materials.Length; i++) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs` around lines 101 - 103,
loopFrames can exceed the baked frame count causing _currentFrame to index
beyond valid rows; fix by clamping the computed loop value to _frameCount before
using it for modulus. Replace the current loop calculation with one that sets
loop = Mathf.Min(loopFrames > 0 ? loopFrames : _frameCount, _frameCount) (or
equivalent), and use that clamped loop when computing _currentFrame =
(_currentFrame + Time.deltaTime * fpsOverride) % loop so _currentFrame never
rolls past the baked frames; update references to loopFrames/_currentFrame in
VATPlayer accordingly.

Comment on lines +215 to +222
_boundsMin = new Vector3(
float.Parse(parsed.Min[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Min[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Min[2], System.Globalization.CultureInfo.InvariantCulture));
_boundsMax = new Vector3(
float.Parse(parsed.Max[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Max[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Max[2], System.Globalization.CultureInfo.InvariantCulture));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard sidecar float parsing to avoid hard exceptions.

float.Parse will throw on malformed values and bypass your false-return error path. Parse defensively and fail with a clear log instead.

Proposed fix
-        _boundsMin = new Vector3(
-            float.Parse(parsed.Min[0], System.Globalization.CultureInfo.InvariantCulture),
-            float.Parse(parsed.Min[1], System.Globalization.CultureInfo.InvariantCulture),
-            float.Parse(parsed.Min[2], System.Globalization.CultureInfo.InvariantCulture));
-        _boundsMax = new Vector3(
-            float.Parse(parsed.Max[0], System.Globalization.CultureInfo.InvariantCulture),
-            float.Parse(parsed.Max[1], System.Globalization.CultureInfo.InvariantCulture),
-            float.Parse(parsed.Max[2], System.Globalization.CultureInfo.InvariantCulture));
+        if (!float.TryParse(parsed.Min[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var minX) ||
+            !float.TryParse(parsed.Min[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var minY) ||
+            !float.TryParse(parsed.Min[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var minZ) ||
+            !float.TryParse(parsed.Max[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var maxX) ||
+            !float.TryParse(parsed.Max[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var maxY) ||
+            !float.TryParse(parsed.Max[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var maxZ)) {
+            Debug.LogError($"[VATPlayer] {name}: os-remap Min/Max contain non-numeric values");
+            return false;
+        }
+        _boundsMin = new Vector3(minX, minY, minZ);
+        _boundsMax = new Vector3(maxX, maxY, maxZ);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_boundsMin = new Vector3(
float.Parse(parsed.Min[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Min[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Min[2], System.Globalization.CultureInfo.InvariantCulture));
_boundsMax = new Vector3(
float.Parse(parsed.Max[0], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Max[1], System.Globalization.CultureInfo.InvariantCulture),
float.Parse(parsed.Max[2], System.Globalization.CultureInfo.InvariantCulture));
if (!float.TryParse(parsed.Min[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var minX) ||
!float.TryParse(parsed.Min[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var minY) ||
!float.TryParse(parsed.Min[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var minZ) ||
!float.TryParse(parsed.Max[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var maxX) ||
!float.TryParse(parsed.Max[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var maxY) ||
!float.TryParse(parsed.Max[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var maxZ)) {
Debug.LogError($"[VATPlayer] {name}: os-remap Min/Max contain non-numeric values");
return false;
}
_boundsMin = new Vector3(minX, minY, minZ);
_boundsMax = new Vector3(maxX, maxY, maxZ);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs` around lines 215 - 222,
Replace the brittle float.Parse calls used to populate _boundsMin and _boundsMax
from parsed.Min/parsed.Max with defensive parsing using float.TryParse (with
System.Globalization.CultureInfo.InvariantCulture) for each component; if any
TryParse fails, log a clear error that includes the offending string(s) and the
context (e.g., which bound/component failed) and return false instead of letting
an exception bubble up—update the code paths around the parsing in VATPlayer
(the block that assigns _boundsMin/_boundsMax) to perform these checks for all 6
values before assigning.

- Sidecar QFile::write short-write check
  VATBaker.cpp ignored the byte count from QFile::write(), so a truncated
  sidecar (disk full, network volume hiccup) would still report ok=true.
  Now compares the return value against the buffer size, removes the
  partial file, and surfaces the failure through BakeResult.error.

- Bake breadcrumbs → file.export category
  VATBakerController used 'ui.action' for bake start/end. That's output
  telemetry, not a click — Sentry split-by-category was mixing it into
  generic UI noise. Switched to 'file.export' (matches CLI's category).

- chooseOutputDir gains ui.action breadcrumbs
  Was silent. Now logs the picker open with the seed dir, then the
  accept/cancel outcome — so a VAT failure can be correlated against
  whether the user actually picked a folder.

- Godot harness: pair PNG to sidecar by basename
  _load_bake() picked the first *-remap_info.json and the first
  *_pos.png independently. If bake_dir held two staged bakes (someone
  re-ran the staging script without clearing), the metadata from one
  would be applied to the texture of the other and produce visually
  plausible but wrong frames. Now derives the basename from the
  picked sidecar and loads <basename>_pos.png explicitly.

- Godot + Unity: clamp loop_frames ≤ frame_count
  A user-set loopFrames > frameCount would push current_frame past
  the position half of the packed texture and into the normal half,
  rendering garbage rows as positions. Both harnesses now Mathf.Min
  / mini against the bake's frame count.

- Unity: defensive float.TryParse for os-remap bounds
  float.Parse throws FormatException on malformed strings, which
  bypassed the bool-returning error path and left Unity in a half-
  configured state. New TryParseBoundsVec helper logs a clear error
  and returns false instead.

- QML Inspector: keyboard-accessible Browse + Bake buttons
  Both were Rectangle + MouseArea with no tab focus or key handling.
  A keyboard-only user couldn't complete the bake workflow from this
  panel. Added activeFocusOnTab, Keys.onSpacePressed/Return/Enter
  handlers that call into the MouseArea click logic, Accessible.role
  + Accessible.name metadata for screen readers, and a focus ring
  via the existing borderColor → highlightColor + border.width=2
  pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@qml/PropertiesPanel.qml`:
- Around line 689-735: Replace the two-way update handler on the TextField by
using onTextEdited instead of onTextChanged (i.e., in animOutField use
onTextEdited: animToolsCol.outputDir = text) so programmatic updates don't drop
the binding, and after the Browse selection (inside animBrowseMa.onClicked where
you call VATBakerController.chooseOutputDir and set animToolsCol.outputDir =
picked) explicitly resync the field by setting animOutField.text = picked;
additionally add a Connections block targeting animToolsCol to handle its
outputDir change (e.g., Connections { target: animToolsCol; onOutputDirChanged:
if (!animOutField.activeFocus) animOutField.text = animToolsCol.outputDir } ) so
external model updates are reflected when the user is not actively editing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: abc4c829-de9d-4378-b43e-212d935edc91

📥 Commits

Reviewing files that changed from the base of the PR and between 5e6b641 and 899477d.

📒 Files selected for processing (5)
  • qml/PropertiesPanel.qml
  • src/VATBaker.cpp
  • src/VATBakerController.cpp
  • tools/godot-vat-test/scripts/VATPlayer.gd
  • tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs
🚧 Files skipped from review as they are similar to previous changes (4)
  • tools/unity-vat-test/Assets/VAT/Scripts/VATPlayer.cs
  • src/VATBakerController.cpp
  • tools/godot-vat-test/scripts/VATPlayer.gd
  • src/VATBaker.cpp

Comment thread qml/PropertiesPanel.qml
Comment on lines +689 to +735
TextField {
id: animOutField
width: parent.width - animToolsCol.labelWidth - 6 - 60 - 6
height: 24
font.pixelSize: 11
text: animToolsCol.outputDir
onTextChanged: animToolsCol.outputDir = text
placeholderText: "/path/to/output"
enabled: !VATBakerController.isBaking
}
Rectangle {
id: animBrowseBtn
width: 60; height: 24; radius: 3
anchors.verticalCenter: parent.verticalCenter
color: animBrowseMa.pressed
? Qt.darker(PropertiesPanelController.headerColor, 1.2)
: (animBrowseMa.containsMouse || animBrowseBtn.activeFocus)
? Qt.lighter(PropertiesPanelController.headerColor, 1.2)
: PropertiesPanelController.headerColor
border.color: animBrowseBtn.activeFocus
? PropertiesPanelController.highlightColor
: PropertiesPanelController.borderColor
border.width: animBrowseBtn.activeFocus ? 2 : 1
activeFocusOnTab: !VATBakerController.isBaking
Accessible.role: Accessible.Button
Accessible.name: "Browse for VAT output folder"
Keys.onSpacePressed: animBrowseMa.clicked(null)
Keys.onReturnPressed: animBrowseMa.clicked(null)
Keys.onEnterPressed: animBrowseMa.clicked(null)
Text {
anchors.centerIn: parent
text: "Browse"
color: PropertiesPanelController.textColor; font.pixelSize: 10
}
MouseArea {
id: animBrowseMa
anchors.fill: parent
hoverEnabled: true
enabled: !VATBakerController.isBaking
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
onClicked: {
if (!animBrowseMa.enabled) return
const picked = VATBakerController.chooseOutputDir(animToolsCol.outputDir)
if (picked && picked.length > 0)
animToolsCol.outputDir = picked
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Qt Quick/QML, if a TextFieldusestext: somePropertyand also updates that property from a change handler, can later imperative assignments tosomePropertystop updating the visible field after user edits? IsonTextEdited plus explicit resync the recommended pattern?

💡 Result:

Yes—an imperative assignment to the same property that originally had a binding can stop the field from reflecting further changes, because the assignment destroys the existing binding. In QML, bindings are replaced/lost when a property is set imperatively (“overwrite the property’s value”), so later changes to the original source won’t automatically propagate to the property anymore [1]. Applied to your scenario: if TextField.text is declared as text: someProperty, that is a binding. If, in some handler, you later do an imperative assignment to someProperty (or otherwise reassign the TextField’s text through that same property), you can end up breaking the intended “live sync” between your model and the visible text. This is the same general binding-removal behavior described for QML properties [1] and diagnosed by Qt’s binding-removal debug logging [2]. Regarding signals: onTextEdited is specifically user-edit driven and is not emitted when text is changed programmatically (e.g., by setting text or calling clear()), which makes it suitable for implementing “write back only when the user edits” logic without feedback loops [3]. Recommended pattern: use onTextEdited (or onEditingFinished if you only want commit at the end) to update your backing property, and avoid imperative assignments that overwrite/replace the TextField’s binding. That is, make the TextField driven by a single source of truth and only update that source from user edits. Example (one-way binding TextField -> model on user edits; model -> TextField via binding): TextField { id: tf text: someProperty // binding onTextEdited: someProperty = text // user-driven update } If you truly need to temporarily override the behavior of the text binding, you’d need to conditionally control the binding or explicitly restore it (e.g., using Qt.binding()), rather than doing unqualified imperative assignments that remove the binding [1].

Citations:


Fix the two-way binding by using onTextEdited instead of onTextChanged and explicitly resync after Browse selections.

When a TextField has a binding like text: animToolsCol.outputDir and also updates the bound property via onTextChanged, later imperative assignments (such as animToolsCol.outputDir = picked from Browse) destroy the binding. This leaves the visible field out of sync with the actual value used during baking.

Qt's binding behavior is explicit: imperative assignments remove bindings. The standard pattern is to use onTextEdited (which fires only on user input, not programmatic changes) for model updates, and rely on the binding to keep the field in sync when the model changes from other sources. Add a Connections block to explicitly resync if the model is updated while the user has focus.

🛠️ Suggested fix
                 TextField {
                     id: animOutField
                     width: parent.width - animToolsCol.labelWidth - 6 - 60 - 6
                     height: 24
                     font.pixelSize: 11
                     text: animToolsCol.outputDir
-                    onTextChanged: animToolsCol.outputDir = text
+                    onTextEdited: animToolsCol.outputDir = text
                     placeholderText: "/path/to/output"
                     enabled: !VATBakerController.isBaking
                 }
+                Connections {
+                    target: animToolsCol
+                    function onOutputDirChanged() {
+                        if (animOutField.text !== animToolsCol.outputDir)
+                            animOutField.text = animToolsCol.outputDir
+                    }
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qml/PropertiesPanel.qml` around lines 689 - 735, Replace the two-way update
handler on the TextField by using onTextEdited instead of onTextChanged (i.e.,
in animOutField use onTextEdited: animToolsCol.outputDir = text) so programmatic
updates don't drop the binding, and after the Browse selection (inside
animBrowseMa.onClicked where you call VATBakerController.chooseOutputDir and set
animToolsCol.outputDir = picked) explicitly resync the field by setting
animOutField.text = picked; additionally add a Connections block targeting
animToolsCol to handle its outputDir change (e.g., Connections { target:
animToolsCol; onOutputDirChanged: if (!animOutField.activeFocus)
animOutField.text = animToolsCol.outputDir } ) so external model updates are
reflected when the user is not actively editing.

Two bugs the user spotted in the Godot harness:

1) One-frame "blob" artifact at certain frames
   Root cause: `filter_nearest` on the packed-normals texture rounds
   (not floors) when computing nearest texel, so V values landing
   near the half-texture boundary at frame N (the last position row)
   could slip into row N (the first normal row). The decoded normal
   vector got reinterpreted as a position offset → vertices shoot to
   "garbage" locations for one frame, then snap back.

   Fix: stop relying on sampler rounding. Compute integer row indices
   `curr_row` / `next_row` from `floor(current_frame)`, sample both
   rows via texelFetch (Godot) / `_PosTex.Load()` (Unity HLSL), then
   `mix()`/`lerp()` based on the fractional part. Bypasses the sampler
   path entirely — there is no boundary to round across.

   Bonus: this is what the sharpen3d/openvat reference shader does
   for the exact same reason (LegacyPBR-GLSL.gdshader L74-91).

   Bonus 2: free inter-frame interpolation, no longer "bit-exact at
   fps fractions" only.

2) Wrong normals (lighting inverted)
   The FBX → Ogre import path applies `aiProcess_ConvertToLeftHanded`,
   which flips winding without flipping the captured normal vector.
   The baked normals point INTO the surface. The previous version of
   this shader (pre-OpenVAT collapse) negated the normal on read; the
   collapse removed the negation, and the "dark where there should be
   light" symptom returned.

   Fix: `NORMAL = -normalize(n)` in Godot; `-n` in the Unity Object →
   World transform. Both restored from pre-OpenVAT-collapse state.

Both fixes applied to both harness shaders so Godot + Unity stay in
visual lockstep.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Copy link
Copy Markdown

@fernandotonon
fernandotonon merged commit 6aae36b into master May 19, 2026
20 checks passed
@fernandotonon
fernandotonon deleted the vat/openvat-only branch May 19, 2026 20:14
fernandotonon added a commit that referenced this pull request May 20, 2026
* feat(vat): UV2-based engine shader templates + harness rewrite

Follow-up to #620. The merged PR landed the OpenVAT-only baker; this
extends the consumer side: harness shaders that handle real-world bake
variants and drop-in engine templates for users.

Harness changes (tools/godot-vat-test/)
---------------------------------------
- Replaced sampler-based frame addressing with `texelFetch` / integer
  row arithmetic. The previous shader's `filter_nearest` rounded (not
  floored) V at the half-texture boundary, producing a one-frame
  position-into-normal slip ("blob artifact"). The new path computes
  `curr_row` / `next_row` manually and `mix()`es — matches sharpen3d's
  reference shader.

- Switched to UV2-based texture addressing. Each vertex's UV2 holds
  its (col, base_row) into the texture — same as the canonical OpenVAT
  Godot shader. Handles both single-row layouts (QtMeshEditor's own
  bakes) AND multi-row tile layouts (Blender's "Use Single Row OFF"
  mode, e.g. the Barril sample). Meshes lacking UV2 get one
  synthesized in `_ensure_uv2_on_mesh` from the bake's known width +
  frame count.

- Auto-detect packed vs. separate-normals layout by filename
  (`*_pos.*` vs `*_vat.*` + `*_vnrm.*`). Separate mode is what Blender
  exports when "Vertex Normals = Separate"; the bundled Barril EXR
  sample is in that mode.

- FBX path through Godot's editor-side import (4.3+). The runtime
  GLTFDocument path is kept for .gltf/.glb. Sidecar JSON is now
  optional with a unit-bounds fallback so a Blender export missing
  remap_info.json renders something instead of nothing.

- Various correctness: Main.gd null-guards before reading VATPlayer
  internals; SkeletalLoader same .fbx/.gltf branching; `loop_frames`
  clamped to `_frame_count`; tscn `source_gltf` path normalized.

Engine shader templates (tools/vat-shaders/)
--------------------------------------------
Drop-in shaders + a one-page README for users who want to play VAT
bakes without writing engine code:

  openvat.gdshader   Godot 4 spatial shader, 100 lines.
  openvat.shader     Unity 2022+ BiRP, 175 lines, includes a URP
                     migration note + a C# UV2-synthesis helper.
  openvat.usf        Unreal 5 HLSL snippet for a Material's Custom
                     node, with full Material-editor wiring walk-
                     through (Surface domain, Shading Model, the
                     ScalarParameter / VectorParameter inputs).
  README.md          Texture-import settings per engine, UV2 gotcha
                     and three ways to satisfy it, sidecar string-
                     float parsing, normal-flip toggle (drop the
                     negate for non-QtMeshEditor sources).

CLI integration:
  - `qtmesh --help` description of `vat` now references the
    `tools/vat-shaders/` directory.
  - post-bake output adds a `shaders:` line pointing at the dir.

Skipped (deliberate)
--------------------
- Separate-EXR output mode in our exporter. Our packed PNG is already
  16-bit per channel, so the only argument for separate EXR is the
  half-float `no_remap` Niagara workflow — not a use case we have a
  real user for yet. The harness consumes Blender's separate-EXR
  bakes for verification; QtMeshEditor only writes the packed format.

- glTF exporter UV2 preservation. `qtmesh convert` strips TEXCOORD_1
  today (Ogre→assimp re-export drops the second channel). Tracked
  follow-up; the README documents the `assimp export` workaround.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* review(vat-test): pair sidecar + texture by basename (P1)

Codex flagged a P1 in VATPlayer._load_bake: the loader picked the
first *-remap_info.json AND the first *_pos/_vat texture
independently. With multiple/partially-staged bakes in `bake_dir`,
this would silently decode pixels from one bake with metadata
(frame count + bounds) from another, producing wrong scale and
animation without failing fast.

Fix: anchor by sidecar basename when one exists. Strip
'-remap_info.json' to get the basename, then probe for
<basename>_pos.{png,exr} (packed) or <basename>_vat.{png,exr}
(separate). Hard error if the matching texture is missing —
no silent fallback to whichever file happens to enumerate first.

Sidecar-less bakes keep the fallback "pick the first texture, derive
basename from it" path because they're already a degraded mode (unit-
bounds fallback); same-folder ambiguity is the user's problem to
clean up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* review(vat): Unreal WPO delta + README fenced-block languages

PR #640 review feedback:

1. CodeRabbit Major: Unreal's WorldPositionOffset expects a DELTA from
   the bind pose, not an absolute object-space position. The previous
   Custom node returned `absolutePos` and the setup instructions
   deferred the subtraction to the material graph as a "remember to
   wire this" note — which is exactly the kind of step users miss.

   Fix: add `LocalPosition` as a Custom node input (wired in via UE's
   built-in `LocalPosition` material node) and return
   `absolutePos - LocalPosition` inside the HLSL. The user now wires
   the Custom node's WorldPositionOffset output straight to the
   material's WPO pin — no extra subtraction node needed. Renamed the
   struct field from `ObjectPos` to `WorldPositionOffset` so the
   output name matches the material pin it's intended for.

2. CodeRabbit Minor (markdownlint MD040): Two fenced code blocks in
   README.md lacked language identifiers. Added `text` for the bake
   file layout diagram and `cpp` for the shader-snippet comment block.

The Codex P1 (sidecar/texture basename pairing) was already addressed
in 53495a1; no further code change needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* feat(vat): Godot demo project — website embed + perf comparison

Three scenes in tools/godot-vat-demo/:

  scenes/demo_web.tscn
    Single Rumba VAT dancer with orbit camera. For the website embed.
    Drag to orbit, wheel/+/- to zoom. Polished lighting + glow + light
    contrast bump for a punchier first impression.

  scenes/demo_perf_vat.tscn
    1000 Rumba instances driven by VAT, in a 32×32 grid with random
    rotations and desynchronised frame phases (so the GPU can't
    optimise away identical work). FPS overlay shows current FPS,
    rolling 1-second window minimum, and worst-since-start.

  scenes/demo_perf_skeleton.tscn
    Same 1000 instances driven by Godot's SkinnedMeshRenderer +
    AnimationPlayer for direct comparison. Each instance is a runtime-
    instantiated PackedScene (loaded once and cached so we don't pay
    1000× GLTFDocument parses at startup) with seek()ed start phase.

Shared infra:
  scripts/VATInstance.gd     — streamlined VAT player (vs. the test
                                harness's `VATPlayer.gd` which has
                                multi-bake + Barril/EXR support).
                                Single bake, packed normals, supports
                                self-driven or externally-driven
                                current_frame for shared-clock setups.
  scripts/OrbitCamera.gd     — mouse drag orbit, wheel + keyboard
                                zoom, configurable distance bounds.
                                Targets the scene's main subject.
  scripts/FPSOverlay.gd      — Label child of a CanvasLayer. Updates
                                4×/sec with rolling-window min FPS so
                                hitches surface clearly (averages hide
                                them).
  scripts/PerfSpawner*.gd    — both spawners build the grid in _ready,
                                stagger frame phases via random seed,
                                share the same source mesh + bake_dir
                                defaults so swapping scenes is a no-op.

Bake assets live at tools/godot-vat-demo/assets/Rumba/ — a copy of
the Rumba bake from tools/godot-vat-test/assets/ so the demo project
is self-contained (the test project keeps its own copy for the
side-by-side harness).

README at tools/godot-vat-demo/README.md covers:
  - How to run each scene
  - Web export steps
  - What to look for in the perf comparison (the rolling minimum is
    the most honest metric)
  - One-paragraph explainer of what VAT actually is for users
    landing on the demo without prior context

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(vat-demo): UV2 must use texture width + global vertex index

The web demo rendered as an "egg of triangles" — every submesh was
sampling the SAME columns of the position texture. Rumba's source.gltf
has 11 submeshes (1024 + 414 + 112 + 1306 + 381 + 454 + 93 + 93 + 381
+ 532 + 1038 = 5828 verts) and the bake's texture is 5828 wide, so
each vertex needs to land on a UNIQUE column.

Two bugs in `_ensure_uv2_on_mesh`:

  1. `width` was computed from `mesh.surface_get_arrays(0)[ARRAY_VERTEX].size()`
     — the FIRST submesh's vertex count (1024). The other 10 submeshes
     wrapped at column 1024 → garbled animation.
  2. `col = j % width` used the PER-SUBMESH index, so vertex 0 of
     every submesh sampled the same column. Visually that overlays
     all submeshes on top of each other → "blob" silhouette.

Fix: pass the bake texture's actual width (`pos_tex.get_width()`) to
the synthesizer, and accumulate a running offset across surfaces so
the column comes from the GLOBAL vertex index, not the per-submesh
one. Mirrors how the test harness's VATPlayer.gd already does it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(vat): qtmesh vat exports a vertex-order-matching source.gltf

The "egg of triangles" symptom in the demos was a vertex-order
mismatch between the bake's texture columns and the source mesh's
vertex indices. The bake walks Ogre's submeshes in submesh-index
order; a separate `qtmesh convert` re-imports the FBX, runs it
through assimp's post-processing (JoinIdenticalVertices,
OptimizeMeshes, cache reordering), and emits the glTF in a
DIFFERENT vertex order. Vertex `i` in the texture column no longer
corresponds to vertex `i` in the glTF, so reconstructed positions
land randomly across the silhouette — egg blob.

Fix: `qtmesh vat` now ALSO writes a `<outDir>/source.gltf`
immediately AFTER importing the FBX and BEFORE running the bake.
Both the bake's collect loop and the glTF exporter iterate the
SAME Ogre entity in the SAME submesh-index order. The bake's
column `i` is now guaranteed to correspond to glTF vertex `i`.

Three subtleties hit during the fix:

  1. Doing the export AFTER the bake produced a malformed glTF
     (meshes-as-dict instead of meshes-as-array). The bake's
     software-skinning request leaves Ogre in a state the exporter
     mishandles. Exporting BEFORE the bake sidesteps this entirely.

  2. The output directory has to be `mkpath`'d before the export —
     `VATBaker::bake` creates it inside its own flow, which is too
     late for the pre-bake glTF write.

  3. `MeshImporterExporter::exporter` takes the display-name format
     string (e.g. "glTF 2.0 (*.gltf)"), not the short id ("gltf2").
     Short forms route to a different code path that produces
     malformed glTF. Use `formatForExtension` to get the right
     name from the output path.

Also re-staged the demo project's `assets/Rumba/source.gltf` against
the new self-exporting `qtmesh vat` so the web + perf demos render
correctly.

The demo project's VATInstance.gd kept its UV2 synthesis math; the
math was correct all along — the source mesh just had the wrong
vertex order.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(vat): texelFetch path + MultiMesh perf spawner

Two related fixes after testing the demo end-to-end.

1) Single-row "egg of triangles" silhouette
   ---------------------------------------------------------------
   Our Rumba bake is 71 frames × 5828 verts in a 5828×142 texture.
   The previous UV2-based shader computed v_pos = 1 - 70/142 - 0.5/142
   = 0.5035, which puts the position sample for frame 0 EXACTLY on
   the V=0.5 boundary between the position half and normal half of
   the texture. With filter_nearest, GPU rounding behavior at .5 is
   hardware-dependent — on Apple Silicon Metal it lands in the
   normal half and decodes encoded normals as positions, producing
   the "egg of triangles" silhouette the user reported.

   Fix: when GDScript synthesizes UV2 (the QtMeshEditor case where
   the imported mesh lacks an authored UV2 channel), pack UV2 as
   INTEGER (column, row_block) pixel coordinates and have the
   shader sample via `texelFetch(pos_tex, ivec2(col, base_row +
   frame), 0)`. Integer indexing has no half-pixel boundary, no
   rounding ambiguity. The shader keeps the textureLod path
   alongside for Blender-authored float UV2 (e.g. the Barril sample),
   gated by a `synthesized_uv2` uniform that the GDScript sets per
   bake source.

   Applied to: tools/godot-vat-test/scripts/VATPlayer.gd (harness),
   tools/godot-vat-demo/scripts/VATInstance.gd (demo),
   tools/vat-shaders/openvat.gdshader (Godot template),
   tools/vat-shaders/openvat.shader (Unity template),
   tools/vat-shaders/openvat.usf (Unreal template). All three
   templates carry the dual-path shader so consumers don't have to
   choose at integration time.

2) VAT perf demo slower than skeleton perf demo
   ---------------------------------------------------------------
   The perf comparison was upside down — VAT should be ~2-3× faster
   than skeletal at 1000 instances, but the user saw the opposite.
   Root cause: my spawner created 1000 independent VATInstance
   nodes, each with its own unique ShaderMaterial → 1000 unique
   materials × 11 submeshes = 11,000 draw calls per frame. Plus
   each instance ran a fresh GLTFDocument load on _ready, so spawn
   time was several seconds.

   Fix: spawn via a single MultiMeshInstance3D. Mesh, texture, and
   shader load ONCE. The crowd is 1000 instance transforms +
   INSTANCE_CUSTOM data fed into one MultiMesh. Result: one batched
   draw call per surface for the whole crowd, ~50 ms to spawn 1000
   instances.

   Per-instance frame phase moves from individual material uniforms
   to `INSTANCE_CUSTOM.r` (Godot's built-in 4-float per-instance
   attribute, readable in the vertex shader). The crowd remains
   desynchronised so the GPU's texture cache can't elide repeated
   work — VAT's honest worst-case cost.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore(vat-demo): uncap FPS on perf scenes + skeleton spawner notes

Two follow-ups after the perf reset:

1) Uncap FPS in both perf scenes
   Default Godot caps render to display vsync — on a ProMotion Mac
   that pins the FPS overlay at 120 even when there's significant
   GPU headroom. Both spawners now call:
     DisplayServer.window_set_vsync_mode(VSYNC_DISABLED)
     Engine.max_fps = 0
   so the overlay shows the actual ceiling and the comparison
   surfaces VAT's real headroom over the skeletal path.

2) Skeleton spawner header comments
   Explain what's already shared across the 1000 instances (Mesh,
   Material, Animation resources via the PackedScene cache;
   automatic in Godot) and what CAN'T be (Skeleton bone state,
   SkinReference, AnimationPlayer time — each instance has its
   own). This is the realistic skinned-NPC path; Godot has no
   built-in MultiMesh equivalent for skinned meshes. The comparison
   against the MultiMesh-VAT spawner is fair real-world.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore(vat-demo): vsync off at project level for perf scenes

The runtime `DisplayServer.window_set_vsync_mode(VSYNC_DISABLED)` in
the perf spawners wasn't taking effect — FPS overlay stayed pinned
at 120 (ProMotion display refresh). Godot's macOS Metal backend
binds vsync at window-creation time, before any GDScript runs, so
the runtime call landed too late.

Fix: set `display/window/vsync/vsync_mode=0` in project.godot. The
runtime call is kept as a defensive belt-and-braces in case the
project setting gets reverted in the editor.

Note this also uncaps the web demo scene at startup. That's
intentional — for a one-character orbiting demo the GPU is doing
basically nothing per frame and the overlay headroom isn't user-
facing anyway. If we ship a polished web build we can flip vsync
back on for the web demo only via a per-scene runtime call.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* feat(website): embed VAT demo as live iframe

The marketing page now has a "Live demo" section (between Mixamo
workflow and Pipeline) embedding the Godot Web export of the VAT
showcase. Drag-to-orbit, scroll-to-zoom, no install needed — a
visitor lands on the page and sees a baked skeletal animation
playing back via vertex shader in real-time.

Pieces
------
tools/godot-vat-demo/export_presets.cfg
  Web export preset. variant/thread_support=false → single-threaded
  build (~38 MB total: 36 MB WASM runtime + 1.7 MB pck + glue
  scripts). Single-thread skips the SharedArrayBuffer requirement,
  so the bundle works in any iframe without COOP/COEP headers.
  Output path: ../../website/public/demo/index.html (relative to
  the demo project), so re-exporting drops files directly into the
  Vite public dir.

tools/godot-vat-demo/scripts/WebDemoMain.gd
  Scene-level controller for demo_web.tscn that re-enables vsync.
  project.godot has vsync OFF (perf scenes need it that way for
  honest FPS measurement); browser-embedded demos want smooth 60 Hz,
  not uncapped CPU/GPU use. Per-scene override via
  DisplayServer.window_set_vsync_mode(VSYNC_ENABLED).

website/public/demo/
  The Godot Web export output (committed). README.md documents how
  to regenerate (`godot --headless --export-release "Web"` from
  the demo project dir).

website/src/App.jsx + App.module.css
  New <Section id="vat-demo"> with an <iframe src="demo/index.html">.
  Loading="lazy" so the 36 MB WASM doesn't block first-paint. 16:9
  aspect-ratio container, max-height: 540px so it doesn't dominate
  tall viewports. Caption block below links to the CLI usage and
  the tools/vat-shaders/ templates for engine integration.

tools/godot-vat-demo/.gitignore
  Standard Godot editor cache excludes (.godot/, *.uid, *.import).

Re-exporting after demo changes
-------------------------------
The preset writes back into website/public/demo/ automatically. The
website build picks up the new files on next `npm run build` —
Vite copies website/public/* verbatim into the build output.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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