feat(vat): --emit-uv2 flag — bake column index into source.gltf#654
Conversation
Adds `qtmesh vat --emit-uv2 [N]` to inject the per-vertex bake-column
index as a TEXCOORD_<N> attribute (default channel 1) directly into
the exported source.gltf. Consumers can then read UV<N> instead of
running the bind-sidecar matcher at runtime — the same data that the
matcher would compute lands in the glTF as a normal vertex attribute,
no special-case loader code needed.
This is what makes the "open .uproject, hit Play" Unreal flow
possible. Same simplification applies to the Godot demo (already
shipping) and any third-party Unity/three.js/blender consumer.
Mechanics:
- cmdVat parses the new flag (default off, channel 1 when bare,
rejects channel 0 to avoid clobbering the diffuse UV).
- After the bake completes successfully AND the bind-sidecar
permutation built a valid mapping (`sourceMeshMatchesBake`),
we open source.gltf, append a new bufferView + accessor per
primitive carrying the column indices, and wire the accessor
into each primitive's attributes as TEXCOORD_<N>.
- The injection is a JSON+binary post-pass on the file Assimp
just wrote. No Ogre-side or Assimp changes; safer than seeding
UV2 before export (Assimp's JoinIdenticalVertices would shuffle
it).
- On any failure (missing .bin, primitive count mismatch, etc.)
the original glTF is left untouched and the user gets a warning
pointing back at the bind-sidecar matcher.
Also fixed a regression in `buildVertexPermutation`'s V handling:
the post-FlipUVs export sometimes preserves and sometimes inverts V
depending on Assimp's internal flag flow. Instead of guessing, the
matcher now tries both V conventions and uses whichever resolves
all vertices. The Ogre→glTF readback in readGltfVertices stops
preemptively 1-V'ing.
Verified end-to-end on Rumba Dancing.fbx — 5828/5828 verts matched,
TEXCOORD_1 written with `min=[0,0]` `max=[1023,0]` for prim 0,
sequential columns per primitive as expected.
|
Warning Rate limit exceeded
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 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. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4ada8899f
ℹ️ 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".
| if (ok && n >= 0 && n <= 7) { | ||
| channel = n; | ||
| ++i; | ||
| } |
There was a problem hiding this comment.
Reject out-of-range
--emit-uv2 channel values
When a user passes an explicit channel outside 0..7 (for example --emit-uv2 8), this branch just leaves the token unconsumed and falls back to channel 1 instead of surfacing an error. That silently violates the documented 1..7 contract and can also mis-parse the command line (e.g., 8 may later be treated as the positional file argument when none was set yet), so users can get the wrong UV channel or a confusing "file not found" failure.
Useful? React with 👍 / 👎.
| if (v > vMax) vMax = v; | ||
| } | ||
| // Append payload to the .bin and add a bufferView + accessor. | ||
| bf.write(payload); |
There was a problem hiding this comment.
Check append writes before committing new accessor offsets
The return value of bf.write(payload) is ignored, so a short/failed write (e.g., disk full) still advances cursorBytes and later updates JSON metadata as if all bytes were appended. In that case the rewritten glTF can reference UV data that was never fully written, producing a corrupted source.gltf while this function still reports success.
Useful? React with 👍 / 👎.
Addresses both Codex notes on PR #654. 1. P1 — `--emit-uv2 N` no longer silently falls back to channel 1 when N is outside the documented 1..7 range. Previously the non-matching numeric token was left unconsumed and could also be mis-parsed as the positional file argument later in the loop, producing a confusing "file not found" instead of a clear "out of range" error. New behaviour: - Distinguishes "next token is an integer literal" from "next token is the file path" via a digit-only check (allowing a leading +/-). - When the next token IS an integer, consumes it unconditionally so it can't fall through. - Range-checks: rejects N < 0, N > 7, and N == 0 with a specific message in each case. - Non-numeric token after `--emit-uv2` is treated as the bare form (channel 1) — matches the previous behaviour. 2. P2 — emitGltfUv2 now checks the return of `bf.write(payload)` per primitive. On a short write (disk full, FS quota, I/O error mid-stream) it truncates the .bin back to its original length and returns a structured error, so the caller doesn't commit JSON bufferView/accessor metadata that references data that was never fully written.
…gltf After PR #654 added `qtmesh vat --emit-uv2`, the bake's source.gltf carries the per-vertex column index as TEXCOORD_1 directly. Unreal's mesh importer reorders vertices for cache locality, but a vertex attribute travels with its vertex through any reorder — so the imported mesh's TexCoord[1] already points at the right column in the position texture. No runtime UV2-baking, no bind-sidecar matcher, no engine-version-specific Geometry Script paths. This turns the previously-incomplete Unreal demo into a true "open and play" setup: 1. Open QtMeshVAT.uproject 2. Run build_vat_demo.py from the Python console 3. Wire BP_VATDancer's 4-node Tick (documented in README) 4. Drop the actor into a level, hit Play. Code changes: - Re-baked Content/Rumba/source.gltf + source.bin with `qtmesh vat --emit-uv2`. The .gltf now carries TEXCOORD_1 on every primitive; size grew from 642 KB to 689 KB for the UV2 payload. - Replaced bake_uv2() (which returned False because it couldn't portably commit the UV2 write) with verify_gltf_has_uv2() — a fast pre-check that fails the bootstrap loudly if a user's bake folder predates --emit-uv2. - Dropped the obsolete read_ogre_bind() + matching plan and the struct/Geometry-Script path references. - Material graph comment updated: TexCoord[1] now comes straight from glTF's TEXCOORD_1, not a post-import EUW write. - README: drop "Step 4: bake UV2" entirely. New error case for "source.gltf is MISSING TEXCOORD_1" points the user at `qtmesh vat --emit-uv2`.
|



Summary
Adds `qtmesh vat --emit-uv2 [N]` to inject the per-vertex bake-column index as a `TEXCOORD_` attribute (default channel 1) directly into the exported `source.gltf`. Consumers can then read UV<N> instead of running the bind-sidecar matcher at runtime — the same data that the matcher would compute lands in the glTF as a normal vertex attribute, no special-case loader code needed.
This is what makes the "open .uproject, hit Play" Unreal flow possible. Same simplification applies to the Godot demo (already shipping) and any third-party Unity / three.js / Blender consumer.
CLI
```bash
bare form: defaults to channel 1
qtmesh vat character.fbx --anim "Dance" --emit-uv2
explicit channel (1..7); channel 0 is rejected to avoid clobbering diffuse UV
qtmesh vat character.fbx --anim "Dance" --emit-uv2 2
```
Sample text output:
```
Baked OpenVAT for 'Dance' (71 frames × 5828 vertices)
texture: bakes/Dance/Dance_pos.png
sidecar: bakes/Dance/Dance-remap_info.json
bind: bakes/Dance/Dance_ogre_bind.bin (per-vertex bind-pose signature; ...)
mesh: bakes/Dance/source.gltf (vertex order matches the bake)
uv2: injected as TEXCOORD_1 — consumers can drop the runtime bind-sidecar
matcher and read (col, row) from the mesh's UV1 directly
bounds: min=(-0.700, -0.100, -0.600) max=(0.600, 2.000, 0.400)
```
JSON output gains `"uv2Channel": 1`.
Mechanics
Drive-by fix
Also fixed a regression in `buildVertexPermutation`'s V handling: the post-FlipUVs export sometimes preserves and sometimes inverts V depending on Assimp's internal flag flow. Instead of guessing, the matcher now tries both V conventions and uses whichever resolves all vertices. The Ogre→glTF readback in `readGltfVertices` stops preemptively 1-V'ing.
Test plan
Related
Unlocks an "open .uproject, hit Play" Unreal demo (currently blocked at the UV2-bake step in #652). Follows up on #648, #649, #651.
🤖 Generated with Claude Code