feature/gltf-pipeline-v2 #18
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user