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. * *

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.

* *

All methods are pure functions (no state, no side effects).

*/ 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. * *

Protects downstream parsers from OOM and negative-length crashes on malformed * or hostile resource packs. Files larger than {@link #MAX_GLB_SIZE} are rejected.

* * @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. * *

Blender auto-weights + float quantization often produce sums slightly ≠ 1 * (commonly 0.98–1.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.

* *

Modifies the array in place. Call once at parse time; zero per-frame cost.

*/ 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). Mutates {@code joints} in place. * 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 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 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): * * * @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. * *

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.

*/ 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. * *

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.

*/ 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. * *

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.

*/ 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. * *

Skin-specific filtering is encoded in {@code nodeToJoint}: channels * targeting a node with a {@code -1} mapping are skipped.

* * @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 rotJoints = new java.util.ArrayList<>(); java.util.List rotTimestamps = new java.util.ArrayList<>(); java.util.List rotValues = new java.util.ArrayList<>(); java.util.List transJoints = new java.util.ArrayList<>(); java.util.List transTimestamps = new java.util.ArrayList<>(); java.util.List 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}. * *

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.

*/ 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 primitives; public final int vertexCount; PrimitiveParseResult( float[] positions, float[] normals, float[] texCoords, int[] indices, int[] joints, float[] weights, List 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. * *

Invariants:

*
    *
  • POSITION is required. NORMAL and TEXCOORD_0 are optional and * default to zero-filled arrays of the correct size.
  • *
  • Per-primitive indices are offset by the running * {@code cumulativeVertexCount} so the flat arrays index * correctly.
  • *
  • {@code JOINTS_0} is read, out-of-range indices clamped to 0 with * a WARN log (once per file, via {@link #clampJointIndices}).
  • *
  • {@code WEIGHTS_0} is read and normalized per-vertex.
  • *
  • Material tintability: name prefix {@code "tintable_"} → per-channel * {@link GltfData.Primitive} entry.
  • *
* * @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 allPositions = new java.util.ArrayList<>(); java.util.List allNormals = new java.util.ArrayList<>(); java.util.List allTexCoords = new java.util.ArrayList<>(); java.util.List allJoints = new java.util.ArrayList<>(); java.util.List allWeights = new java.util.ArrayList<>(); java.util.List 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. * *

For animation data, see {@link #convertAnimationToMinecraftSpace}.

* *
    *
  • Vertex positions / normals: (x, y, z) → (-x, -y, z)
  • *
  • Rest translations: same negation
  • *
  • Rest rotations (quaternions): negate qx and qy (conjugation by 180° Z)
  • *
  • Inverse bind matrices: M → C·M·C where C = diag(-1, -1, 1)
  • *
*/ 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; } } } } } }