Files
TiedUp-/src/test/java/com/tiedup/remake/client/gltf/GlbParserUtilsTest.java
NotEvil 355e2936c9 Refactor V2 animation, furniture, and GLTF rendering
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.
2026-04-18 17:34:03 +02:00

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);
}
}
}