Files
TiedUp-/src/main/java/com/tiedup/remake/client/gltf/GlbParserUtils.java
NotEvil 11188bc621 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

1079 lines
40 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Shared stateless utilities for parsing binary glTF (.glb) files.
*
* <p>These methods are used by both {@link GlbParser} (single-armature bondage meshes)
* and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser}
* (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim
* duplication between the two parsers.</p>
*
* <p>All methods are pure functions (no state, no side effects).</p>
*/
public final class GlbParserUtils {
// glTF component type constants
public static final int BYTE = 5120;
public static final int UNSIGNED_BYTE = 5121;
public static final int SHORT = 5122;
public static final int UNSIGNED_SHORT = 5123;
public static final int UNSIGNED_INT = 5125;
public static final int FLOAT = 5126;
/** Maximum allowed GLB file size to prevent OOM from malformed/hostile assets. */
public static final int MAX_GLB_SIZE = 50 * 1024 * 1024; // 50 MB
// GLB binary format constants (shared between parsers and validator).
public static final int GLB_MAGIC = 0x46546C67; // "glTF"
public static final int GLB_VERSION = 2;
public static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
public static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParserUtils() {}
/**
* Safely read a GLB stream into a little-endian ByteBuffer positioned past the
* 12-byte header, after validating magic, version, and total length.
*
* <p>Protects downstream parsers from OOM and negative-length crashes on malformed
* or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.</p>
*
* @param input the input stream (will be read)
* @param debugName name included in diagnostic messages
* @return a buffer positioned at the start of the first chunk, with
* remaining bytes exactly equal to {@code totalLength - 12}
* @throws IOException on bad header, size cap exceeded, or truncation
*/
public static ByteBuffer readGlbSafely(InputStream input, String debugName)
throws IOException {
byte[] header = input.readNBytes(12);
if (header.length < 12) {
throw new IOException("GLB truncated in header: " + debugName);
}
ByteBuffer hdr = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
int magic = hdr.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = hdr.getInt();
if (version != GLB_VERSION) {
throw new IOException(
"Unsupported GLB version " + version + " in " + debugName
);
}
int totalLength = hdr.getInt();
if (totalLength < 12) {
throw new IOException(
"GLB total length " + totalLength + " too small in " + debugName
);
}
if (totalLength > MAX_GLB_SIZE) {
throw new IOException(
"GLB size " +
totalLength +
" exceeds cap " +
MAX_GLB_SIZE +
" in " +
debugName
);
}
int bodyLen = totalLength - 12;
byte[] body = input.readNBytes(bodyLen);
if (body.length < bodyLen) {
throw new IOException(
"GLB truncated: expected " +
bodyLen +
" body bytes, got " +
body.length +
" in " +
debugName
);
}
return ByteBuffer.wrap(body).order(ByteOrder.LITTLE_ENDIAN);
}
/**
* Normalize per-vertex skinning weights so each 4-tuple sums to 1.0.
*
* <p>Blender auto-weights + float quantization often produce sums slightly ≠ 1
* (commonly 0.981.02). LBS without normalization scales the vertex by that
* factor — tiny error per vertex, visible drift over a full mesh.
* Tuples that sum to effectively zero (no influence) are left alone so
* downstream code can treat them as "un-skinned" if needed.</p>
*
* <p>Modifies the array in place. Call once at parse time; zero per-frame cost.</p>
*/
public static void normalizeWeights(float[] weights) {
if (weights == null) return;
if (weights.length % 4 != 0) {
// WEIGHTS_0 is VEC4 per glTF spec; a non-multiple-of-4 array is malformed.
// We still process the well-formed prefix.
org.apache.logging.log4j.LogManager.getLogger(
"GltfPipeline"
).warn(
"[GltfPipeline] WEIGHTS_0 array length {} is not a multiple of 4 (malformed); trailing {} values ignored",
weights.length,
weights.length % 4
);
}
for (int i = 0; i + 3 < weights.length; i += 4) {
float sum =
weights[i] + weights[i + 1] + weights[i + 2] + weights[i + 3];
if (sum > 1.0e-6f && Math.abs(sum - 1.0f) > 1.0e-4f) {
float inv = 1.0f / sum;
weights[i] *= inv;
weights[i + 1] *= inv;
weights[i + 2] *= inv;
weights[i + 3] *= inv;
}
}
}
/**
* Clamp joint indices into the valid range [0, jointCount), remapping
* out-of-range indices to 0 (root). <b>Mutates {@code joints} in place.</b>
* Returns the number of clamps performed so the caller can log a single
* warning when a file is malformed.
*/
public static int clampJointIndices(int[] joints, int jointCount) {
if (joints == null) return 0;
int clamped = 0;
for (int i = 0; i < joints.length; i++) {
if (joints[i] < 0 || joints[i] >= jointCount) {
joints[i] = 0;
clamped++;
}
}
return clamped;
}
/**
* Read a chunk length from the buffer and validate it fits within remaining bytes.
* @throws IOException if length is negative or exceeds remaining
*/
public static int readChunkLength(
ByteBuffer buf,
String chunkName,
String debugName
) throws IOException {
int len = buf.getInt();
if (len < 0) {
throw new IOException(
"Negative " + chunkName + " chunk length in " + debugName
);
}
if (len > buf.remaining() - 4) {
// -4 for the chunk-type field that follows
throw new IOException(
chunkName +
" chunk length " +
len +
" exceeds remaining bytes in " +
debugName
);
}
return len;
}
// ---- Material name parsing ----
/**
* Parse the root "materials" array and extract each material's "name" field.
* Returns an empty array if no materials are present.
*/
public static String[] parseMaterialNames(JsonObject root) {
if (!root.has("materials") || !root.get("materials").isJsonArray()) {
return new String[0];
}
JsonArray materials = root.getAsJsonArray("materials");
String[] names = new String[materials.size()];
for (int i = 0; i < materials.size(); i++) {
JsonObject mat = materials.get(i).getAsJsonObject();
names[i] = mat.has("name") ? mat.get("name").getAsString() : null;
}
return names;
}
// ---- Array flattening utilities ----
public static float[] flattenFloats(List<float[]> arrays) {
int total = 0;
for (float[] a : arrays) total += a.length;
float[] result = new float[total];
int offset = 0;
for (float[] a : arrays) {
System.arraycopy(a, 0, result, offset, a.length);
offset += a.length;
}
return result;
}
public static int[] flattenInts(List<int[]> arrays) {
int total = 0;
for (int[] a : arrays) total += a.length;
int[] result = new int[total];
int offset = 0;
for (int[] a : arrays) {
System.arraycopy(a, 0, result, offset, a.length);
offset += a.length;
}
return result;
}
// ---- Accessor reading utilities ----
public static float[] readFloatAccessor(
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int accessorIdx
) {
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
int count = accessor.get("count").getAsInt();
int componentType = accessor.get("componentType").getAsInt();
String type = accessor.get("type").getAsString();
int components = typeComponents(type);
int bvIdx = accessor.get("bufferView").getAsInt();
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
int byteOffset =
(bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) +
(accessor.has("byteOffset")
? accessor.get("byteOffset").getAsInt()
: 0);
int byteStride = bv.has("byteStride")
? bv.get("byteStride").getAsInt()
: 0;
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
int totalElements = validateAccessorBounds(
count,
components,
componentSize,
byteOffset,
stride,
binData.capacity()
);
float[] result = new float[totalElements];
// Seek once per element; the sequential reads in readComponentAsFloat
// advance the buffer through the components. Explicit per-component
// seeks are redundant because component c+1 is already at the right
// offset after reading c.
for (int i = 0; i < count; i++) {
binData.position(byteOffset + i * stride);
for (int c = 0; c < components; c++) {
result[i * components + c] = readComponentAsFloat(
binData,
componentType
);
}
}
return result;
}
public static int[] readIntAccessor(
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int accessorIdx
) {
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
int count = accessor.get("count").getAsInt();
int componentType = accessor.get("componentType").getAsInt();
String type = accessor.get("type").getAsString();
int components = typeComponents(type);
int bvIdx = accessor.get("bufferView").getAsInt();
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
int byteOffset =
(bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0) +
(accessor.has("byteOffset")
? accessor.get("byteOffset").getAsInt()
: 0);
int byteStride = bv.has("byteStride")
? bv.get("byteStride").getAsInt()
: 0;
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
int totalElements = validateAccessorBounds(
count,
components,
componentSize,
byteOffset,
stride,
binData.capacity()
);
int[] result = new int[totalElements];
// Seek once per element — see readFloatAccessor comment.
for (int i = 0; i < count; i++) {
binData.position(byteOffset + i * stride);
for (int c = 0; c < components; c++) {
result[i * components + c] = readComponentAsInt(
binData,
componentType
);
}
}
return result;
}
/**
* Reject malformed/hostile accessors before allocating. Prevents OOM from a
* JSON declaring e.g. {@code count=5e8, type=MAT4} (~8 GB) within a legitimately
* sized GLB. Checks (all must hold):
* <ul>
* <li>{@code count >= 0} and {@code components >= 1}</li>
* <li>{@code count * components} doesn't overflow int</li>
* <li>{@code byteOffset + stride * (count - 1) + components * componentSize <= binCapacity}</li>
* </ul>
*
* @return {@code count * components} (the allocation size)
*/
private static int validateAccessorBounds(
int count,
int components,
int componentSize,
int byteOffset,
int stride,
int binCapacity
) {
if (count < 0) {
throw new IllegalArgumentException(
"Accessor count must be non-negative: " + count
);
}
if (components < 1) {
throw new IllegalArgumentException(
"Accessor components must be >= 1: " + components
);
}
if (byteOffset < 0 || stride < 0 || componentSize < 1) {
throw new IllegalArgumentException(
"Accessor has negative byteOffset/stride or zero componentSize"
);
}
int totalElements;
try {
totalElements = Math.multiplyExact(count, components);
} catch (ArithmeticException overflow) {
throw new IllegalArgumentException(
"Accessor count * components overflows int: " +
count +
" * " +
components
);
}
if (count == 0) {
return 0;
}
// Bytes required: byteOffset + stride*(count-1) + components*componentSize
long lastElementStart =
(long) byteOffset + (long) stride * (long) (count - 1);
long elementBytes = (long) components * (long) componentSize;
long required = lastElementStart + elementBytes;
if (required > binCapacity) {
throw new IllegalArgumentException(
"Accessor would read past BIN chunk: needs " +
required +
" bytes, buffer has " +
binCapacity
);
}
return totalElements;
}
/**
* Read one component as a normalized float in [1, 1] (signed) or [0, 1]
* (unsigned). {@code UNSIGNED_INT} is defensive — glTF 2.0 §3.6.2.3 only
* lists BYTE/UBYTE/SHORT/USHORT as valid normalized types; an out-of-spec
* exporter hitting that branch gets a best-effort divide by 0xFFFFFFFF.
*/
public static float readComponentAsFloat(
ByteBuffer buf,
int componentType
) {
return switch (componentType) {
case FLOAT -> buf.getFloat();
case BYTE -> buf.get() / 127.0f;
case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f;
case SHORT -> buf.getShort() / 32767.0f;
case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f;
case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) /
(float) 0xFFFFFFFFL;
default -> throw new IllegalArgumentException(
"Unknown component type: " + componentType
);
};
}
public static int readComponentAsInt(ByteBuffer buf, int componentType) {
return switch (componentType) {
case BYTE -> buf.get();
case UNSIGNED_BYTE -> buf.get() & 0xFF;
case SHORT -> buf.getShort();
case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF;
case UNSIGNED_INT -> buf.getInt();
case FLOAT -> (int) buf.getFloat();
default -> throw new IllegalArgumentException(
"Unknown component type: " + componentType
);
};
}
public static int typeComponents(String type) {
return switch (type) {
case "SCALAR" -> 1;
case "VEC2" -> 2;
case "VEC3" -> 3;
case "VEC4" -> 4;
case "MAT4" -> 16;
default -> throw new IllegalArgumentException(
"Unknown accessor type: " + type
);
};
}
public static int componentByteSize(int componentType) {
return switch (componentType) {
case BYTE, UNSIGNED_BYTE -> 1;
case SHORT, UNSIGNED_SHORT -> 2;
case UNSIGNED_INT, FLOAT -> 4;
default -> throw new IllegalArgumentException(
"Unknown component type: " + componentType
);
};
}
// ---- Deep-copy utility ----
/**
* Deep-copy an AnimationClip (preserves original data before MC conversion).
*/
public static GltfData.AnimationClip deepCopyClip(
GltfData.AnimationClip clip
) {
Quaternionf[][] rawRotations =
new Quaternionf[clip.rotations().length][];
for (int j = 0; j < clip.rotations().length; j++) {
if (clip.rotations()[j] != null) {
rawRotations[j] = new Quaternionf[clip.rotations()[j].length];
for (int f = 0; f < clip.rotations()[j].length; f++) {
rawRotations[j][f] = new Quaternionf(
clip.rotations()[j][f]
);
}
}
}
Vector3f[][] rawTranslations = null;
if (clip.translations() != null) {
rawTranslations = new Vector3f[clip.translations().length][];
for (int j = 0; j < clip.translations().length; j++) {
if (clip.translations()[j] != null) {
rawTranslations[j] =
new Vector3f[clip.translations()[j].length];
for (int f = 0; f < clip.translations()[j].length; f++) {
rawTranslations[j][f] = new Vector3f(
clip.translations()[j][f]
);
}
}
}
}
return new GltfData.AnimationClip(
clip.timestamps().clone(),
rawRotations,
rawTranslations,
clip.frameCount()
);
}
// ---- Naming conventions ----
/**
* True when a mesh name follows the {@code Player*} convention — typically
* a player-armature mesh or seat-armature mesh that the item/furniture
* pipelines must skip. Null-safe.
*
* <p>Historically {@link GlbParser} and {@link
* com.tiedup.remake.client.gltf.diagnostic.GlbValidator GlbValidator}
* used {@code "Player".equals(name)} while
* {@code FurnitureGlbParser} used {@code startsWith("Player")}, so a mesh
* named {@code "Player_foo"} was accepted by the item pipeline but
* rejected by the furniture pipeline. Consolidated on the more defensive
* {@code startsWith} variant — matches the artist guide's
* {@code Player_*} seat armature naming convention.</p>
*/
public static boolean isPlayerMesh(@Nullable String meshName) {
return meshName != null && meshName.startsWith("Player");
}
/**
* Strip a Blender-style armature prefix from a bone or animation name.
* {@code "ArmatureName|bone"} → {@code "bone"}. Keeps everything after
* the last {@code |}. Returns the input unchanged if it has no pipe,
* and returns null if the input is null.
*
* <p>All joint/animation/validator reads must go through this helper —
* a site that stores the raw prefixed name silently breaks artists
* with non-default armature names.</p>
*/
public static String stripArmaturePrefix(@Nullable String name) {
if (name == null) return null;
int pipeIdx = name.lastIndexOf('|');
return pipeIdx >= 0 ? name.substring(pipeIdx + 1) : name;
}
// ---- Node rest pose extraction ----
/**
* Read a node's rest rotation from its glTF JSON representation. Returns
* identity quaternion (0, 0, 0, 1) when no {@code rotation} field is present.
*
* <p>Extracted from 3 call sites in {@link GlbParser} /
* {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser
* FurnitureGlbParser} that all had identical logic. Each parser's per-joint
* loop now delegates here to avoid silent drift.</p>
*/
public static Quaternionf readRestRotation(JsonObject node) {
if (node != null && node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
return new Quaternionf(
r.get(0).getAsFloat(),
r.get(1).getAsFloat(),
r.get(2).getAsFloat(),
r.get(3).getAsFloat()
);
}
return new Quaternionf(); // identity
}
/**
* Read a node's rest translation. Returns the zero vector when no
* {@code translation} field is present. See {@link #readRestRotation}.
*/
public static Vector3f readRestTranslation(JsonObject node) {
if (node != null && node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
return new Vector3f(
t.get(0).getAsFloat(),
t.get(1).getAsFloat(),
t.get(2).getAsFloat()
);
}
return new Vector3f();
}
// ---- Bone hierarchy ----
/**
* Build the parent-joint index array by traversing the glTF node children.
*
* @param nodes the top-level {@code nodes} array from the glTF JSON
* @param nodeToJoint mapping: {@code nodeIdx → jointIdx} (use {@code -1}
* for nodes that are not part of the skin)
* @param jointCount the size of the resulting array
* @return array of size {@code jointCount} where index {@code j} holds the
* parent joint index, or {@code -1} for roots
*/
public static int[] buildParentJointIndices(
JsonArray nodes,
int[] nodeToJoint,
int jointCount
) {
int[] parentJointIndices = new int[jointCount];
for (int j = 0; j < jointCount; j++) parentJointIndices[j] = -1;
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (!node.has("children")) continue;
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
// Malformed GLBs may declare a child index outside `nodes`;
// silently skip rather than AIOOBE.
if (childNodeIdx < 0 || childNodeIdx >= nodeToJoint.length) {
continue;
}
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
return parentJointIndices;
}
// ---- Animation parsing ----
/**
* Parse a single {@code glTF animation} JSON object into an
* {@link GltfData.AnimationClip}. Returns {@code null} when the animation
* has no channels that map to the current skin.
*
* <p>Skin-specific filtering is encoded in {@code nodeToJoint}: channels
* targeting a node with a {@code -1} mapping are skipped.</p>
*
* @param animation a single entry from the root {@code animations} array
* @param accessors the root {@code accessors} array
* @param bufferViews the root {@code bufferViews} array
* @param binData the BIN chunk buffer
* @param nodeToJoint mapping from glTF node index to joint index; use
* {@code -1} for nodes not in the skin
* @param jointCount size of the skin
* @return parsed clip, or {@code null} if no channels matched the skin
*/
@Nullable
public static GltfData.AnimationClip parseAnimation(
JsonObject animation,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint,
int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
java.util.List<Integer> rotJoints = new java.util.ArrayList<>();
java.util.List<float[]> rotTimestamps = new java.util.ArrayList<>();
java.util.List<Quaternionf[]> rotValues = new java.util.ArrayList<>();
java.util.List<Integer> transJoints = new java.util.ArrayList<>();
java.util.List<float[]> transTimestamps = new java.util.ArrayList<>();
java.util.List<Vector3f[]> transValues = new java.util.ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4],
quats[i * 4 + 1],
quats[i * 4 + 2],
quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = readFloatAccessor(
accessors,
bufferViews,
binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3],
vecs[i * 3 + 1],
vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] =
f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] =
f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
return new GltfData.AnimationClip(
timestamps,
rotations,
translations,
frameCount
);
}
// ---- Primitive mesh parsing ----
/**
* Result of parsing a mesh's primitives: flat per-attribute arrays plus
* per-primitive metadata. All array lengths are in sync with
* {@link #vertexCount}.
*
* <p>When the parsing request had {@code readSkinning = false} (mesh-only
* path used by {@code FurnitureGlbParser.buildMeshOnlyGltfData}), the
* {@link #joints} and {@link #weights} arrays are empty.</p>
*/
public static final class PrimitiveParseResult {
public final float[] positions;
public final float[] normals;
public final float[] texCoords;
public final int[] indices;
public final int[] joints;
public final float[] weights;
public final List<GltfData.Primitive> primitives;
public final int vertexCount;
PrimitiveParseResult(
float[] positions,
float[] normals,
float[] texCoords,
int[] indices,
int[] joints,
float[] weights,
List<GltfData.Primitive> primitives,
int vertexCount
) {
this.positions = positions;
this.normals = normals;
this.texCoords = texCoords;
this.indices = indices;
this.joints = joints;
this.weights = weights;
this.primitives = primitives;
this.vertexCount = vertexCount;
}
}
/**
* Parse every primitive of a mesh, accumulate per-attribute buffers, and
* flatten the result.
*
* <p>Invariants:</p>
* <ul>
* <li>POSITION is required. NORMAL and TEXCOORD_0 are optional and
* default to zero-filled arrays of the correct size.</li>
* <li>Per-primitive indices are offset by the running
* {@code cumulativeVertexCount} so the flat arrays index
* correctly.</li>
* <li>{@code JOINTS_0} is read, out-of-range indices clamped to 0 with
* a WARN log (once per file, via {@link #clampJointIndices}).</li>
* <li>{@code WEIGHTS_0} is read and normalized per-vertex.</li>
* <li>Material tintability: name prefix {@code "tintable_"} → per-channel
* {@link GltfData.Primitive} entry.</li>
* </ul>
*
* @param mesh a single entry from the root {@code meshes} array
* @param accessors root {@code accessors} array
* @param bufferViews root {@code bufferViews} array
* @param binData the BIN chunk buffer
* @param jointCount skin joint count (used to clamp JOINTS_0); pass
* {@code 0} when {@code readSkinning} is false
* @param readSkinning true to read JOINTS_0 / WEIGHTS_0, false for
* mesh-only (furniture placer fallback)
* @param materialNames material name lookup (from
* {@link #parseMaterialNames}); may contain nulls
* @param debugName file/resource name for diagnostics
* @return the consolidated parse result
*/
public static PrimitiveParseResult parsePrimitives(
JsonObject mesh,
JsonArray accessors,
JsonArray bufferViews,
ByteBuffer binData,
int jointCount,
boolean readSkinning,
String[] materialNames,
String debugName
) {
JsonArray primitives = mesh.getAsJsonArray("primitives");
java.util.List<float[]> allPositions = new java.util.ArrayList<>();
java.util.List<float[]> allNormals = new java.util.ArrayList<>();
java.util.List<float[]> allTexCoords = new java.util.ArrayList<>();
java.util.List<int[]> allJoints = new java.util.ArrayList<>();
java.util.List<float[]> allWeights = new java.util.ArrayList<>();
java.util.List<GltfData.Primitive> parsedPrimitives =
new java.util.ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
float[] primPositions = readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("POSITION").getAsInt()
);
float[] primNormals = attributes.has("NORMAL")
? readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("NORMAL").getAsInt()
)
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("TEXCOORD_0").getAsInt()
)
: new float[(primPositions.length / 3) * 2];
int primVertexCount = primPositions.length / 3;
int[] primIndices;
if (primitive.has("indices")) {
primIndices = readIntAccessor(
accessors,
bufferViews,
binData,
primitive.get("indices").getAsInt()
);
} else {
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
}
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (readSkinning) {
if (attributes.has("JOINTS_0")) {
primJoints = readIntAccessor(
accessors,
bufferViews,
binData,
attributes.get("JOINTS_0").getAsInt()
);
int clamped = clampJointIndices(primJoints, jointCount);
if (clamped > 0) {
org.apache.logging.log4j.LogManager.getLogger(
"GltfPipeline"
).warn(
"[GltfPipeline] Clamped {} out-of-range joint indices in '{}'",
clamped,
debugName
);
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = readFloatAccessor(
accessors,
bufferViews,
binData,
attributes.get("WEIGHTS_0").getAsInt()
);
normalizeWeights(primWeights);
}
}
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (
matIdx >= 0 &&
materialNames != null &&
matIdx < materialNames.length
) {
matName = materialNames[matIdx];
}
}
boolean isTintable =
matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(
new GltfData.Primitive(
primIndices,
matName,
isTintable,
tintChannel
)
);
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
if (readSkinning) {
allJoints.add(primJoints);
allWeights.add(primWeights);
}
cumulativeVertexCount += primVertexCount;
}
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives)
totalIndices += p.indices().length;
int[] indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(
p.indices(),
0,
indices,
offset,
p.indices().length
);
offset += p.indices().length;
}
return new PrimitiveParseResult(
flattenFloats(allPositions),
flattenFloats(allNormals),
flattenFloats(allTexCoords),
indices,
readSkinning ? flattenInts(allJoints) : new int[0],
readSkinning ? flattenFloats(allWeights) : new float[0],
parsedPrimitives,
cumulativeVertexCount
);
}
// ---- Coordinate system conversion ----
/**
* Convert mesh-level spatial data from glTF space to Minecraft model-def
* space. glTF and MC model-def both face -Z; only X (right→left) and Y
* (up→down) differ. Equivalent to a 180° rotation around Z: negate X and Y.
*
* <p>For animation data, see {@link #convertAnimationToMinecraftSpace}.</p>
*
* <ul>
* <li>Vertex positions / normals: (x, y, z) → (-x, -y, z)</li>
* <li>Rest translations: same negation</li>
* <li>Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)</li>
* <li>Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)</li>
* </ul>
*/
public static void convertMeshToMinecraftSpace(
float[] positions,
float[] normals,
Vector3f[] restTranslations,
Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices
) {
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i];
positions[i + 1] = -positions[i + 1];
}
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
}
/**
* Convert an animation clip's rotations and translations to MC space.
* Negate qx/qy for rotations and negate tx/ty for translations.
*/
public static void convertAnimationToMinecraftSpace(
GltfData.AnimationClip clip,
int jointCount
) {
if (clip == null) return;
Quaternionf[][] rotations = clip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
Vector3f[][] translations = clip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}