Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.
Parsing and rendering
- Shared GLB parsing helpers consolidated into GlbParserUtils
(accessor reads, weight normalization, joint-index clamping,
coordinate-space conversion, animation parse, primitive loop).
- Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
GltfLiveBoneReader — removes per-frame joint-matrix allocation
from the render hot path.
- emitVertex helper dedups three parallel loops in GltfMeshRenderer.
- TintColorResolver.resolve has a zero-alloc path when the item
declares no tint channels.
- itemAnimCache bounded to 256 entries (access-order LRU) with
atomic get-or-compute under the map's monitor.
Animation correctness
- First-in-joint-order wins when body and torso both map to the
same PlayerAnimator slot; duplicate writes log a single WARN.
- Multi-item composites honor the FullX / FullHeadX opt-in that
the single-item path already recognized.
- Seat transforms converted to Minecraft model-def space so
asymmetric furniture renders passengers at the correct offset.
- GlbValidator: IBM count / type / presence, JOINTS_0 presence,
animation channel target validation, multi-skin support.
Furniture correctness and anti-exploit
- Seat assignment synced via SynchedEntityData (server is
authoritative; eliminates client-server divergence on multi-seat).
- Force-mount authorization requires same dimension and a free
seat; cross-dimension distance checks rejected.
- Reconnection on login checks for seat takeover before re-mount
and force-loads the target chunk for cross-dimension cases.
- tiedup_furniture_lockpick_ctx carries a session UUID nonce so
stale context can't misroute a body-item lockpick.
- tiedup_locked_furniture survives death without keepInventory
(Forge 1.20.1 does not auto-copy persistent data on respawn).
Lifecycle and memory
- EntityCleanupHandler fans EntityLeaveLevelEvent out to every
per-entity state map on the client.
- DogPoseRenderHandler re-keyed by UUID (stable across dimension
change; entity int ids are recycled).
- PetBedRenderHandler, PlayerArmHideEventHandler, and
HeldItemHideHandler use receiveCanceled + sentinel sets so
Pre-time mutations are restored even when a downstream handler
cancels the render.
Tests
- JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
FurnitureSeatGeometry, and FurnitureAuthPredicate.
711 lines
26 KiB
Java
711 lines
26 KiB
Java
package com.tiedup.remake.client.gltf;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
import com.google.gson.JsonArray;
|
|
import com.google.gson.JsonObject;
|
|
import com.google.gson.JsonParser;
|
|
import java.io.ByteArrayInputStream;
|
|
import java.io.IOException;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.ByteOrder;
|
|
import org.joml.Matrix4f;
|
|
import org.joml.Quaternionf;
|
|
import org.joml.Vector3f;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Nested;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
/**
|
|
* Unit tests for {@link GlbParserUtils}.
|
|
*
|
|
* <p>Covers the parser safety surface introduced to defeat the OOM / silent-drift
|
|
* bugs found in the 2026-04-17 audit:</p>
|
|
* <ul>
|
|
* <li>{@link GlbParserUtils#readGlbSafely(java.io.InputStream, String)} header cap</li>
|
|
* <li>{@link GlbParserUtils#readChunkLength(java.nio.ByteBuffer, String, String)} bounds</li>
|
|
* <li>{@link GlbParserUtils#normalizeWeights(float[])} correctness</li>
|
|
* <li>{@link GlbParserUtils#clampJointIndices(int[], int)} contract</li>
|
|
* </ul>
|
|
*/
|
|
class GlbParserUtilsTest {
|
|
|
|
/**
|
|
* Build a minimum-size GLB header: 4B magic, 4B version, 4B totalLength.
|
|
* Returns the 12 bytes; no chunks follow.
|
|
*/
|
|
private static byte[] minimalHeader(int totalLength) {
|
|
ByteBuffer buf = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
|
|
buf.putInt(GlbParserUtils.GLB_MAGIC);
|
|
buf.putInt(GlbParserUtils.GLB_VERSION);
|
|
buf.putInt(totalLength);
|
|
return buf.array();
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("readGlbSafely — OOM/truncation guards")
|
|
class ReadGlbSafelyTests {
|
|
|
|
@Test
|
|
@DisplayName("rejects files exceeding MAX_GLB_SIZE cap")
|
|
void rejectsOversized() {
|
|
// Claim a totalLength 10x the cap, but only provide 12 bytes of header.
|
|
byte[] header = minimalHeader(GlbParserUtils.MAX_GLB_SIZE + 1);
|
|
IOException ex = assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readGlbSafely(
|
|
new ByteArrayInputStream(header),
|
|
"hostile.glb"
|
|
)
|
|
);
|
|
assertTrue(
|
|
ex.getMessage().contains("exceeds cap"),
|
|
"message should mention cap: " + ex.getMessage()
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("rejects negative totalLength")
|
|
void rejectsNegativeTotalLength() {
|
|
byte[] header = minimalHeader(-1);
|
|
IOException ex = assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readGlbSafely(
|
|
new ByteArrayInputStream(header),
|
|
"negative.glb"
|
|
)
|
|
);
|
|
// Either the "too small" or "exceeds cap" path is acceptable —
|
|
// what matters is that negative lengths don't slip through.
|
|
assertTrue(
|
|
ex.getMessage().contains("too small") ||
|
|
ex.getMessage().contains("exceeds cap"),
|
|
"message should reject negative length: " + ex.getMessage()
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("rejects wrong magic bytes")
|
|
void rejectsWrongMagic() {
|
|
ByteBuffer buf = ByteBuffer.allocate(12).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
buf.putInt(0xDEADBEEF); // wrong magic
|
|
buf.putInt(GlbParserUtils.GLB_VERSION);
|
|
buf.putInt(100);
|
|
IOException ex = assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readGlbSafely(
|
|
new ByteArrayInputStream(buf.array()),
|
|
"junk.glb"
|
|
)
|
|
);
|
|
assertTrue(
|
|
ex.getMessage().contains("Not a GLB"),
|
|
"message should reject bad magic: " + ex.getMessage()
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("rejects wrong version")
|
|
void rejectsWrongVersion() {
|
|
ByteBuffer buf = ByteBuffer.allocate(12).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
buf.putInt(GlbParserUtils.GLB_MAGIC);
|
|
buf.putInt(999); // future version
|
|
buf.putInt(100);
|
|
IOException ex = assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readGlbSafely(
|
|
new ByteArrayInputStream(buf.array()),
|
|
"future.glb"
|
|
)
|
|
);
|
|
assertTrue(
|
|
ex.getMessage().contains("Unsupported GLB version"),
|
|
"message should mention version: " + ex.getMessage()
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("rejects truncated body (totalLength promises more than stream provides)")
|
|
void rejectsTruncatedBody() {
|
|
// Claim 100 bytes total, but only supply the 12-byte header.
|
|
byte[] header = minimalHeader(100);
|
|
IOException ex = assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readGlbSafely(
|
|
new ByteArrayInputStream(header),
|
|
"truncated.glb"
|
|
)
|
|
);
|
|
assertTrue(
|
|
ex.getMessage().contains("truncated"),
|
|
"message should mention truncation: " + ex.getMessage()
|
|
);
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("readChunkLength — buffer bounds")
|
|
class ReadChunkLengthTests {
|
|
|
|
@Test
|
|
@DisplayName("rejects negative length")
|
|
void rejectsNegativeLength() {
|
|
ByteBuffer buf = ByteBuffer.allocate(8).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
buf.putInt(-5); // negative length
|
|
buf.putInt(GlbParserUtils.CHUNK_JSON); // type follows, space reserved
|
|
buf.position(0);
|
|
assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readChunkLength(buf, "JSON", "bad.glb")
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("rejects length exceeding remaining")
|
|
void rejectsOverflowingLength() {
|
|
// Buffer capacity 16: 4B length + 4B type + 8B payload.
|
|
// Claim length=100 which doesn't fit.
|
|
ByteBuffer buf = ByteBuffer.allocate(16).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
buf.putInt(100);
|
|
buf.putInt(GlbParserUtils.CHUNK_JSON);
|
|
buf.position(0);
|
|
assertThrows(IOException.class, () ->
|
|
GlbParserUtils.readChunkLength(buf, "JSON", "overflow.glb")
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("accepts valid length")
|
|
void acceptsValidLength() throws IOException {
|
|
ByteBuffer buf = ByteBuffer.allocate(16).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
buf.putInt(8); // 8 bytes payload (will follow)
|
|
buf.putInt(GlbParserUtils.CHUNK_JSON);
|
|
buf.position(0);
|
|
// 4 (type) + 8 (payload) == 12 remaining after length read; len=8 must
|
|
// be <= remaining-4 = 8. Passes.
|
|
int len = GlbParserUtils.readChunkLength(buf, "JSON", "ok.glb");
|
|
assertEquals(8, len);
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("normalizeWeights")
|
|
class NormalizeWeightsTests {
|
|
|
|
@Test
|
|
@DisplayName("normalizes a tuple summing to < 1.0")
|
|
void normalizesLowSum() {
|
|
float[] w = { 0.5f, 0.3f, 0.1f, 0.0f }; // sum 0.9
|
|
GlbParserUtils.normalizeWeights(w);
|
|
float sum = w[0] + w[1] + w[2] + w[3];
|
|
assertEquals(1.0f, sum, 1.0e-5f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("normalizes a tuple summing to > 1.0")
|
|
void normalizesHighSum() {
|
|
float[] w = { 0.6f, 0.4f, 0.2f, 0.0f }; // sum 1.2
|
|
GlbParserUtils.normalizeWeights(w);
|
|
float sum = w[0] + w[1] + w[2] + w[3];
|
|
assertEquals(1.0f, sum, 1.0e-5f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("leaves already-normalized tuple within ε untouched")
|
|
void leavesNormalizedUntouched() {
|
|
float[] w = { 0.4f, 0.3f, 0.2f, 0.1f }; // sum exactly 1.0
|
|
float[] orig = w.clone();
|
|
GlbParserUtils.normalizeWeights(w);
|
|
assertArrayEquals(orig, w, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("leaves zero-sum tuple alone (un-skinned vertex)")
|
|
void leavesZeroSumAlone() {
|
|
float[] w = { 0.0f, 0.0f, 0.0f, 0.0f };
|
|
GlbParserUtils.normalizeWeights(w);
|
|
float sum = w[0] + w[1] + w[2] + w[3];
|
|
assertEquals(0.0f, sum, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("processes multiple tuples independently")
|
|
void multipleTuples() {
|
|
float[] w = {
|
|
0.5f, 0.3f, 0.1f, 0.0f, // tuple 1: sum 0.9
|
|
0.6f, 0.4f, 0.2f, 0.0f, // tuple 2: sum 1.2
|
|
0.4f, 0.3f, 0.2f, 0.1f, // tuple 3: sum 1.0 (untouched)
|
|
};
|
|
GlbParserUtils.normalizeWeights(w);
|
|
assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
|
|
assertEquals(1.0f, w[4] + w[5] + w[6] + w[7], 1.0e-5f);
|
|
assertEquals(1.0f, w[8] + w[9] + w[10] + w[11], 1.0e-5f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("tolerates non-multiple-of-4 length (processes well-formed prefix, ignores tail)")
|
|
void nonMultipleOfFourLength() {
|
|
// 7 elements: one complete tuple + 3 orphan weights.
|
|
float[] w = { 0.5f, 0.3f, 0.1f, 0.0f, 0.9f, 0.9f, 0.9f };
|
|
GlbParserUtils.normalizeWeights(w);
|
|
assertEquals(1.0f, w[0] + w[1] + w[2] + w[3], 1.0e-5f);
|
|
// Trailing elements should be untouched (not normalized).
|
|
assertEquals(0.9f, w[4], 1.0e-6f);
|
|
assertEquals(0.9f, w[5], 1.0e-6f);
|
|
assertEquals(0.9f, w[6], 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("null array is a safe no-op")
|
|
void nullArrayIsSafe() {
|
|
GlbParserUtils.normalizeWeights(null);
|
|
// No exception = pass.
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("isPlayerMesh / stripArmaturePrefix")
|
|
class NamingConventionTests {
|
|
|
|
@Test
|
|
@DisplayName("isPlayerMesh: exact name 'Player' matches")
|
|
void isPlayerExact() {
|
|
assertTrue(GlbParserUtils.isPlayerMesh("Player"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("isPlayerMesh: prefixed name 'Player_main' matches (startsWith)")
|
|
void isPlayerPrefixed() {
|
|
assertTrue(GlbParserUtils.isPlayerMesh("Player_main"));
|
|
assertTrue(GlbParserUtils.isPlayerMesh("Player_left"));
|
|
assertTrue(GlbParserUtils.isPlayerMesh("Player_foo"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("isPlayerMesh: non-Player name rejected")
|
|
void isPlayerRejectsItem() {
|
|
assertFalse(GlbParserUtils.isPlayerMesh("Item"));
|
|
assertFalse(GlbParserUtils.isPlayerMesh(""));
|
|
assertFalse(GlbParserUtils.isPlayerMesh("player")); // lowercase
|
|
assertFalse(GlbParserUtils.isPlayerMesh("MyMesh"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("isPlayerMesh: null is safe and rejected")
|
|
void isPlayerNullSafe() {
|
|
assertFalse(GlbParserUtils.isPlayerMesh(null));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("stripArmaturePrefix: 'MyRig|bone' → 'bone'")
|
|
void stripsPipe() {
|
|
assertEquals("bone", GlbParserUtils.stripArmaturePrefix("MyRig|bone"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("stripArmaturePrefix: name without pipe is unchanged")
|
|
void noPipeUnchanged() {
|
|
assertEquals("body", GlbParserUtils.stripArmaturePrefix("body"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("stripArmaturePrefix: multiple pipes → keep everything after LAST")
|
|
void multiplePipesLastWins() {
|
|
assertEquals("C", GlbParserUtils.stripArmaturePrefix("A|B|C"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("stripArmaturePrefix: prefix-only '|body' → 'body'")
|
|
void prefixOnly() {
|
|
assertEquals("body", GlbParserUtils.stripArmaturePrefix("|body"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("stripArmaturePrefix: trailing pipe 'body|' → empty string")
|
|
void trailingPipe() {
|
|
assertEquals("", GlbParserUtils.stripArmaturePrefix("body|"));
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("stripArmaturePrefix: null returns null")
|
|
void nullReturnsNull() {
|
|
assertNull(GlbParserUtils.stripArmaturePrefix(null));
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("readRestRotation / readRestTranslation")
|
|
class ReadRestPoseTests {
|
|
|
|
private JsonObject parseNode(String json) {
|
|
return JsonParser.parseString(json).getAsJsonObject();
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("empty node returns identity quaternion")
|
|
void emptyRotation() {
|
|
Quaternionf q = GlbParserUtils.readRestRotation(parseNode("{}"));
|
|
assertEquals(0.0f, q.x, 1.0e-6f);
|
|
assertEquals(0.0f, q.y, 1.0e-6f);
|
|
assertEquals(0.0f, q.z, 1.0e-6f);
|
|
assertEquals(1.0f, q.w, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("empty node returns zero vector")
|
|
void emptyTranslation() {
|
|
Vector3f t = GlbParserUtils.readRestTranslation(parseNode("{}"));
|
|
assertEquals(0.0f, t.x, 1.0e-6f);
|
|
assertEquals(0.0f, t.y, 1.0e-6f);
|
|
assertEquals(0.0f, t.z, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("node with rotation reads all four components in XYZW order")
|
|
void readsRotation() {
|
|
Quaternionf q = GlbParserUtils.readRestRotation(
|
|
parseNode("{\"rotation\": [0.1, 0.2, 0.3, 0.9]}")
|
|
);
|
|
assertEquals(0.1f, q.x, 1.0e-6f);
|
|
assertEquals(0.2f, q.y, 1.0e-6f);
|
|
assertEquals(0.3f, q.z, 1.0e-6f);
|
|
assertEquals(0.9f, q.w, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("node with translation reads all three components")
|
|
void readsTranslation() {
|
|
Vector3f t = GlbParserUtils.readRestTranslation(
|
|
parseNode("{\"translation\": [1.5, -2.5, 3.0]}")
|
|
);
|
|
assertEquals(1.5f, t.x, 1.0e-6f);
|
|
assertEquals(-2.5f, t.y, 1.0e-6f);
|
|
assertEquals(3.0f, t.z, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("null node returns default values (no NPE)")
|
|
void nullNode() {
|
|
Quaternionf q = GlbParserUtils.readRestRotation(null);
|
|
Vector3f t = GlbParserUtils.readRestTranslation(null);
|
|
assertEquals(1.0f, q.w, 1.0e-6f);
|
|
assertEquals(0.0f, t.x, 1.0e-6f);
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("buildParentJointIndices")
|
|
class BuildParentJointIndicesTests {
|
|
|
|
/** Helper to build a JsonArray of node-like JsonObjects. */
|
|
private JsonArray parseNodes(String json) {
|
|
return JsonParser.parseString(json).getAsJsonArray();
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("flat skeleton (no children) → all roots")
|
|
void flatSkeleton() {
|
|
JsonArray nodes = parseNodes("[{}, {}, {}]");
|
|
int[] nodeToJoint = { 0, 1, 2 };
|
|
int[] parents = GlbParserUtils.buildParentJointIndices(
|
|
nodes,
|
|
nodeToJoint,
|
|
3
|
|
);
|
|
assertArrayEquals(new int[] { -1, -1, -1 }, parents);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("simple tree: root with two children")
|
|
void simpleTree() {
|
|
// Node 0: root with children [1, 2]
|
|
// Node 1: leaf
|
|
// Node 2: leaf
|
|
JsonArray nodes = parseNodes(
|
|
"[{\"children\": [1, 2]}, {}, {}]"
|
|
);
|
|
int[] nodeToJoint = { 0, 1, 2 };
|
|
int[] parents = GlbParserUtils.buildParentJointIndices(
|
|
nodes,
|
|
nodeToJoint,
|
|
3
|
|
);
|
|
assertEquals(-1, parents[0]); // root
|
|
assertEquals(0, parents[1]);
|
|
assertEquals(0, parents[2]);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("nested tree: root → child → grandchild")
|
|
void nestedTree() {
|
|
JsonArray nodes = parseNodes(
|
|
"[{\"children\": [1]}, {\"children\": [2]}, {}]"
|
|
);
|
|
int[] nodeToJoint = { 0, 1, 2 };
|
|
int[] parents = GlbParserUtils.buildParentJointIndices(
|
|
nodes,
|
|
nodeToJoint,
|
|
3
|
|
);
|
|
assertArrayEquals(new int[] { -1, 0, 1 }, parents);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("non-joint node in hierarchy is skipped")
|
|
void nonJointSkipped() {
|
|
// Nodes 0 is non-joint (-1 mapping), 1 and 2 are joints.
|
|
// Node 0 children = [1, 2] but node 0 is not a joint → no parent set.
|
|
JsonArray nodes = parseNodes(
|
|
"[{\"children\": [1, 2]}, {}, {}]"
|
|
);
|
|
int[] nodeToJoint = { -1, 0, 1 };
|
|
int[] parents = GlbParserUtils.buildParentJointIndices(
|
|
nodes,
|
|
nodeToJoint,
|
|
2
|
|
);
|
|
assertArrayEquals(new int[] { -1, -1 }, parents);
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("parseAnimation")
|
|
class ParseAnimationTests {
|
|
|
|
/** Build a minimal in-memory GLB accessor layout: one FLOAT SCALAR, one VEC4. */
|
|
private JsonObject parseJson(String s) {
|
|
return JsonParser.parseString(s).getAsJsonObject();
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("animation with no matching channels returns null")
|
|
void noMatchingChannels() {
|
|
// Channel targets node 5, but nodeToJoint only covers 0-2.
|
|
JsonObject anim = parseJson(
|
|
"{" +
|
|
"\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 5, \"path\": \"rotation\"}}]," +
|
|
"\"samplers\": [{\"input\": 0, \"output\": 1}]" +
|
|
"}"
|
|
);
|
|
JsonArray accessors = parseJson(
|
|
"{\"a\":[{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0}]}"
|
|
).getAsJsonArray("a");
|
|
JsonArray bufferViews = parseJson(
|
|
"{\"b\":[{\"byteLength\":4,\"byteOffset\":0}]}"
|
|
).getAsJsonArray("b");
|
|
ByteBuffer bin = ByteBuffer.allocate(16).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
int[] nodeToJoint = { 0, 1, 2 };
|
|
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
|
|
anim,
|
|
accessors,
|
|
bufferViews,
|
|
bin,
|
|
nodeToJoint,
|
|
3
|
|
);
|
|
assertNull(
|
|
clip,
|
|
"no channels target the skin joints — should return null"
|
|
);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("single rotation channel produces per-joint rotations array")
|
|
void singleRotationChannel() {
|
|
// Layout:
|
|
// accessor 0: 1 SCALAR timestamp float at byteOffset 0 (4 bytes)
|
|
// accessor 1: 1 VEC4 quat floats at byteOffset 4 (16 bytes)
|
|
// Channel: node 1 (jointIdx 1), path "rotation", sampler 0.
|
|
JsonObject anim = parseJson(
|
|
"{" +
|
|
"\"channels\": [{\"sampler\": 0, \"target\": {\"node\": 1, \"path\": \"rotation\"}}]," +
|
|
"\"samplers\": [{\"input\": 0, \"output\": 1}]" +
|
|
"}"
|
|
);
|
|
JsonArray accessors = parseJson(
|
|
"{\"a\":[" +
|
|
"{\"count\":1,\"componentType\":5126,\"type\":\"SCALAR\",\"bufferView\":0,\"byteOffset\":0}," +
|
|
"{\"count\":1,\"componentType\":5126,\"type\":\"VEC4\",\"bufferView\":1,\"byteOffset\":0}" +
|
|
"]}"
|
|
).getAsJsonArray("a");
|
|
JsonArray bufferViews = parseJson(
|
|
"{\"b\":[" +
|
|
"{\"byteLength\":4,\"byteOffset\":0}," +
|
|
"{\"byteLength\":16,\"byteOffset\":4}" +
|
|
"]}"
|
|
).getAsJsonArray("b");
|
|
ByteBuffer bin = ByteBuffer.allocate(20).order(
|
|
ByteOrder.LITTLE_ENDIAN
|
|
);
|
|
bin.putFloat(0, 0.0f); // timestamp 0
|
|
bin.putFloat(4, 0.1f); // quat x
|
|
bin.putFloat(8, 0.2f); // quat y
|
|
bin.putFloat(12, 0.3f); // quat z
|
|
bin.putFloat(16, 0.9f); // quat w
|
|
int[] nodeToJoint = { -1, 1, -1 }; // only node 1 → joint 1
|
|
|
|
GltfData.AnimationClip clip = GlbParserUtils.parseAnimation(
|
|
anim,
|
|
accessors,
|
|
bufferViews,
|
|
bin,
|
|
nodeToJoint,
|
|
2
|
|
);
|
|
assertNotNull(clip);
|
|
assertEquals(1, clip.frameCount());
|
|
assertArrayEquals(new float[] { 0.0f }, clip.timestamps(), 1.0e-6f);
|
|
|
|
Quaternionf[][] rots = clip.rotations();
|
|
assertEquals(2, rots.length);
|
|
assertNull(rots[0], "joint 0 has no rotation channel");
|
|
assertNotNull(rots[1]);
|
|
assertEquals(0.1f, rots[1][0].x, 1.0e-6f);
|
|
assertEquals(0.9f, rots[1][0].w, 1.0e-6f);
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("convertMeshToMinecraftSpace")
|
|
class ConvertMeshTests {
|
|
|
|
@Test
|
|
@DisplayName("negates X and Y on positions, normals, rest translations")
|
|
void negatesSpatialData() {
|
|
float[] positions = { 1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f };
|
|
float[] normals = { 0.1f, 0.2f, 0.3f };
|
|
Vector3f[] restT = { new Vector3f(7.0f, 8.0f, 9.0f) };
|
|
Quaternionf[] restR = { new Quaternionf(0.1f, 0.2f, 0.3f, 0.9f) };
|
|
Matrix4f[] ibm = { new Matrix4f().identity() };
|
|
|
|
GlbParserUtils.convertMeshToMinecraftSpace(
|
|
positions,
|
|
normals,
|
|
restT,
|
|
restR,
|
|
ibm
|
|
);
|
|
|
|
assertArrayEquals(
|
|
new float[] { -1.0f, -2.0f, 3.0f, -4.0f, -5.0f, 6.0f },
|
|
positions,
|
|
1.0e-6f
|
|
);
|
|
assertArrayEquals(
|
|
new float[] { -0.1f, -0.2f, 0.3f },
|
|
normals,
|
|
1.0e-6f
|
|
);
|
|
assertEquals(-7.0f, restT[0].x, 1.0e-6f);
|
|
assertEquals(-8.0f, restT[0].y, 1.0e-6f);
|
|
assertEquals(9.0f, restT[0].z, 1.0e-6f);
|
|
assertEquals(-0.1f, restR[0].x, 1.0e-6f);
|
|
assertEquals(-0.2f, restR[0].y, 1.0e-6f);
|
|
assertEquals(0.3f, restR[0].z, 1.0e-6f);
|
|
assertEquals(0.9f, restR[0].w, 1.0e-6f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("IBM transform C·M·C inverts itself: applying twice returns original")
|
|
void ibmDoubleInvertsToIdentity() {
|
|
Matrix4f orig = new Matrix4f().rotateY(0.5f).translate(1f, 2f, 3f);
|
|
Matrix4f ibm = new Matrix4f(orig);
|
|
|
|
GlbParserUtils.convertMeshToMinecraftSpace(
|
|
new float[0],
|
|
new float[0],
|
|
new Vector3f[0],
|
|
new Quaternionf[0],
|
|
new Matrix4f[] { ibm }
|
|
);
|
|
GlbParserUtils.convertMeshToMinecraftSpace(
|
|
new float[0],
|
|
new float[0],
|
|
new Vector3f[0],
|
|
new Quaternionf[0],
|
|
new Matrix4f[] { ibm }
|
|
);
|
|
|
|
// C·C = identity, so C·(C·M·C)·C = M
|
|
float[] origVals = new float[16];
|
|
float[] ibmVals = new float[16];
|
|
orig.get(origVals);
|
|
ibm.get(ibmVals);
|
|
assertArrayEquals(origVals, ibmVals, 1.0e-5f);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("empty arrays are a safe no-op")
|
|
void emptyArraysSafe() {
|
|
GlbParserUtils.convertMeshToMinecraftSpace(
|
|
new float[0],
|
|
new float[0],
|
|
new Vector3f[0],
|
|
new Quaternionf[0],
|
|
new Matrix4f[0]
|
|
);
|
|
}
|
|
}
|
|
|
|
@Nested
|
|
@DisplayName("clampJointIndices")
|
|
class ClampJointIndicesTests {
|
|
|
|
@Test
|
|
@DisplayName("clamps out-of-range positive indices to 0")
|
|
void clampsPositiveOutOfRange() {
|
|
int[] joints = { 0, 1, 999, 5 };
|
|
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
|
assertArrayEquals(new int[] { 0, 1, 0, 5 }, joints);
|
|
assertEquals(1, clamped);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("clamps negative indices to 0")
|
|
void clampsNegatives() {
|
|
int[] joints = { 0, -5, 3, -1 };
|
|
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
|
assertArrayEquals(new int[] { 0, 0, 3, 0 }, joints);
|
|
assertEquals(2, clamped);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("all-valid array: no clamps, returns 0")
|
|
void allValidNoClamp() {
|
|
int[] joints = { 0, 1, 2, 3 };
|
|
int[] orig = joints.clone();
|
|
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
|
assertArrayEquals(orig, joints);
|
|
assertEquals(0, clamped);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("null array is a safe no-op, returns 0")
|
|
void nullArrayIsSafe() {
|
|
int clamped = GlbParserUtils.clampJointIndices(null, 10);
|
|
assertEquals(0, clamped);
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("jointCount boundary: index == jointCount is out-of-range")
|
|
void boundaryEqualIsOutOfRange() {
|
|
int[] joints = { 0, 9, 10 }; // 10 is OOB when jointCount=10
|
|
int clamped = GlbParserUtils.clampJointIndices(joints, 10);
|
|
assertArrayEquals(new int[] { 0, 9, 0 }, joints);
|
|
assertEquals(1, clamped);
|
|
}
|
|
}
|
|
}
|