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.
1079 lines
40 KiB
Java
1079 lines
40 KiB
Java
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.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.</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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|