feature/gltf-pipeline-v2 #18

Merged
NotEvil merged 19 commits from feature/gltf-pipeline-v2 into develop 2026-04-17 02:07:45 +00:00
Showing only changes of commit c0c53f9504 - Show all commits

View File

@@ -0,0 +1,360 @@
package com.tiedup.remake.client.gltf.diagnostic;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.tiedup.remake.client.gltf.GltfBoneMapper;
import com.tiedup.remake.client.gltf.diagnostic.GlbDiagnostic.Severity;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Lightweight structural validator for GLB files.
*
* <p>Reads only the 12-byte header and the JSON chunk (first chunk) — never
* touches the binary mesh data. Produces a list of {@link GlbDiagnostic}
* findings that can range from hard errors (invalid header, missing skin) to
* informational notes (custom bone names).</p>
*
* <p>All methods are static; the class cannot be instantiated.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class GlbValidator {
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private GlbValidator() {}
/**
* Validate a GLB file by reading its header and JSON chunk.
*
* @param input the raw GLB byte stream (will be fully consumed)
* @param source resource location of the GLB file (used in diagnostics)
* @return a validation result containing all findings
*/
public static GlbValidationResult validate(
InputStream input,
ResourceLocation source
) {
List<GlbDiagnostic> diagnostics = new ArrayList<>();
JsonObject root;
try {
root = readJsonChunk(input, source, diagnostics);
} catch (Exception e) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"Failed to read GLB header or JSON chunk: " + e.getMessage()
));
return GlbValidationResult.of(source, diagnostics);
}
// If readJsonChunk added an ERROR, root will be null
if (root == null) {
return GlbValidationResult.of(source, diagnostics);
}
validateSkins(root, source, diagnostics);
validateMeshes(root, source, diagnostics);
validateAnimations(root, source, diagnostics);
return GlbValidationResult.of(source, diagnostics);
}
/**
* Cross-reference a validation result against an item definition.
* Stub for future checks (e.g. region/bone coverage, animation name
* matching against definition-declared poses).
*
* @param result the prior structural validation result
* @param def the item definition to cross-reference
* @return additional diagnostics (currently empty)
*/
public static List<GlbDiagnostic> validateAgainstDefinition(
GlbValidationResult result,
DataDrivenItemDefinition def
) {
return Collections.emptyList();
}
// ------------------------------------------------------------------ //
// Header + JSON chunk extraction //
// ------------------------------------------------------------------ //
/**
* Parse the GLB header and extract the JSON chunk root object.
* Returns null and adds ERROR diagnostics on failure.
*/
private static JsonObject readJsonChunk(
InputStream input,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) throws Exception {
byte[] allBytes = input.readAllBytes();
if (allBytes.length < 12) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"File too small to contain a GLB header (" + allBytes.length + " bytes)"
));
return null;
}
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN);
// -- Header (12 bytes) --
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
String.format("Bad magic number: 0x%08X (expected 0x%08X)", magic, GLB_MAGIC)
));
return null;
}
int version = buf.getInt();
if (version != GLB_VERSION) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"Unsupported GLB version " + version + " (expected " + GLB_VERSION + ")"
));
return null;
}
@SuppressWarnings("unused")
int totalLength = buf.getInt();
// -- First chunk must be JSON --
if (buf.remaining() < 8) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"File truncated: no chunk header after GLB header"
));
return null;
}
int jsonChunkLength = buf.getInt();
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
String.format(
"First chunk is not JSON: type 0x%08X (expected 0x%08X)",
jsonChunkType, CHUNK_JSON
)
));
return null;
}
if (buf.remaining() < jsonChunkLength) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "INVALID_GLB_HEADER",
"File truncated: JSON chunk declares " + jsonChunkLength
+ " bytes but only " + buf.remaining() + " remain"
));
return null;
}
byte[] jsonBytes = new byte[jsonChunkLength];
buf.get(jsonBytes);
String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
return JsonParser.parseString(jsonStr).getAsJsonObject();
}
// ------------------------------------------------------------------ //
// Skin / bone validation //
// ------------------------------------------------------------------ //
private static void validateSkins(
JsonObject root,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("skins")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "NO_SKINS",
"GLB has no 'skins' array — skinned mesh rendering requires at least one skin"
));
return;
}
JsonArray skins = root.getAsJsonArray("skins");
if (skins.size() == 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.ERROR, "NO_SKINS",
"GLB 'skins' array is empty — skinned mesh rendering requires at least one skin"
));
return;
}
// Validate bones in the first skin
JsonObject skin = skins.get(0).getAsJsonObject();
if (!skin.has("joints")) {
return;
}
JsonArray joints = skin.getAsJsonArray("joints");
JsonArray nodes = root.has("nodes") ? root.getAsJsonArray("nodes") : null;
if (nodes == null) {
return;
}
for (int j = 0; j < joints.size(); j++) {
int nodeIdx = joints.get(j).getAsInt();
if (nodeIdx < 0 || nodeIdx >= nodes.size()) {
continue;
}
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
if (!node.has("name")) {
continue;
}
String rawName = node.get("name").getAsString();
// Strip armature prefix (e.g. "MyRig|body" -> "body")
String boneName = rawName.contains("|")
? rawName.substring(rawName.lastIndexOf('|') + 1)
: rawName;
if (GltfBoneMapper.isKnownBone(boneName)) {
// OK — known bone, no diagnostic needed
continue;
}
String suggestion = GltfBoneMapper.suggestBoneName(boneName);
if (suggestion != null) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "BONE_TYPO_SUGGESTION",
"Bone '" + boneName + "' is not recognized — did you mean '"
+ suggestion + "'?"
));
} else {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.INFO, "UNKNOWN_BONE",
"Bone '" + boneName + "' is not a standard MC bone (treated as custom bone)"
));
}
}
}
// ------------------------------------------------------------------ //
// Mesh validation //
// ------------------------------------------------------------------ //
private static void validateMeshes(
JsonObject root,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("meshes")) {
return;
}
JsonArray meshes = root.getAsJsonArray("meshes");
// Count non-Player meshes
int nonPlayerCount = 0;
for (int mi = 0; mi < meshes.size(); mi++) {
JsonObject mesh = meshes.get(mi).getAsJsonObject();
String meshName = mesh.has("name")
? mesh.get("name").getAsString()
: "";
if (!"Player".equals(meshName)) {
nonPlayerCount++;
}
}
if (nonPlayerCount > 1) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "MULTI_MESH_AMBIGUITY",
nonPlayerCount + " non-Player meshes found — name your item mesh 'Item' "
+ "for explicit selection"
));
}
// Check WEIGHTS_0 on the first mesh's first primitive
if (meshes.size() > 0) {
JsonObject firstMesh = meshes.get(0).getAsJsonObject();
if (firstMesh.has("primitives")) {
JsonArray primitives = firstMesh.getAsJsonArray("primitives");
if (primitives.size() > 0) {
JsonObject firstPrimitive = primitives.get(0).getAsJsonObject();
if (firstPrimitive.has("attributes")) {
JsonObject attributes = firstPrimitive.getAsJsonObject("attributes");
if (!attributes.has("WEIGHTS_0")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_WEIGHTS",
"First mesh's first primitive has no WEIGHTS_0 attribute "
+ "— skinning will not work correctly"
));
}
}
}
}
}
}
// ------------------------------------------------------------------ //
// Animation validation //
// ------------------------------------------------------------------ //
private static void validateAnimations(
JsonObject root,
ResourceLocation source,
List<GlbDiagnostic> diagnostics
) {
if (!root.has("animations")) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
"GLB has no 'animations' array — no Idle animation found"
));
return;
}
JsonArray animations = root.getAsJsonArray("animations");
if (animations.size() == 0) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
"GLB 'animations' array is empty — no Idle animation found"
));
return;
}
boolean hasIdle = false;
for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject();
if (!anim.has("name")) {
continue;
}
String animName = anim.get("name").getAsString();
// Strip armature prefix (e.g. "Armature|Idle" -> "Idle")
if (animName.contains("|")) {
animName = animName.substring(animName.lastIndexOf('|') + 1);
}
if ("Idle".equals(animName)) {
hasIdle = true;
break;
}
}
if (!hasIdle) {
diagnostics.add(new GlbDiagnostic(
source, null, Severity.WARNING, "NO_IDLE_ANIMATION",
"No animation named 'Idle' found — the default rest pose may not display correctly"
));
}
}
}