package com.tiedup.remake.client.gltf; import dev.kosmx.playerAnim.core.data.AnimationFormat; import dev.kosmx.playerAnim.core.data.KeyframeAnimation; import dev.kosmx.playerAnim.core.util.Ease; import java.util.HashSet; import java.util.Set; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; import org.joml.Quaternionf; import org.joml.Vector3f; /** * Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation. *

* Data is expected to be already in MC coordinate space (converted by GlbParser). * For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll). * For lower bones: extracts bend angle from delta quaternion. *

* The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376), * so no angle scaling is needed. If the pivots don't match, fix the Blender model. *

* Produces a static looping pose (beginTick=0, endTick=1, looped). */ @OnlyIn(Dist.CLIENT) public final class GltfPoseConverter { private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); private GltfPoseConverter() {} /** * Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation. * Uses the default (first) animation clip. * GltfData must already be in MC coordinate space. * * @param data parsed glTF data (in MC space) * @return a static looping KeyframeAnimation suitable for PlayerAnimator */ public static KeyframeAnimation convert(GltfData data) { return convertClip(data, data.rawGltfAnimation(), "gltf_pose"); } /** * Convert a specific named animation from GltfData to a KeyframeAnimation. * Falls back to the default animation if the name is not found. * * @param data parsed glTF data (in MC space) * @param animationName the name of the animation to convert (e.g. "Struggle", "Idle") * @return a static looping KeyframeAnimation suitable for PlayerAnimator */ public static KeyframeAnimation convert( GltfData data, String animationName ) { GltfData.AnimationClip rawClip = data.getRawAnimation(animationName); if (rawClip == null) { LOGGER.warn( "[GltfPipeline] Animation '{}' not found, falling back to default", animationName ); return convert(data); } return convertClip(data, rawClip, "gltf_" + animationName); } /** * Convert a GLB animation with selective part enabling and free-bone support. * *

Owned parts are always enabled in the output animation. Free parts (in * {@code enabledParts} but not in {@code ownedParts}) are only enabled if the * GLB contains actual keyframe data for them. Parts not in {@code enabledParts} * at all are always disabled (pass through to lower layers).

* * @param data parsed glTF data (in MC space) * @param animationName animation name in GLB, or null for default * @param ownedParts parts the item explicitly owns (always enabled) * @param enabledParts parts the item may animate (owned + free); free parts * are only enabled if the GLB has keyframes for them * @return KeyframeAnimation with selective parts active */ public static KeyframeAnimation convertSelective( GltfData data, @Nullable String animationName, Set ownedParts, Set enabledParts ) { GltfData.AnimationClip rawClip; String animName; if (animationName != null) { rawClip = data.getRawAnimation(animationName); animName = "gltf_" + animationName; } else { rawClip = null; animName = "gltf_pose"; } if (rawClip == null) { rawClip = data.rawGltfAnimation(); } return convertClipSelective( data, rawClip, animName, ownedParts, enabledParts ); } /** * Internal: convert a specific raw animation clip with selective part enabling * and free-bone support. * *

Tracks which PlayerAnimator parts received actual keyframe data from the GLB. * A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}. * This information is used by {@link #enableSelectiveParts} to decide whether * free parts should be enabled.

* * @param ownedParts parts the item explicitly owns (always enabled) * @param enabledParts parts the item may animate (owned + free) */ private static KeyframeAnimation convertClipSelective( GltfData data, GltfData.AnimationClip rawClip, String animName, Set ownedParts, Set enabledParts ) { KeyframeAnimation.AnimationBuilder builder = new KeyframeAnimation.AnimationBuilder( AnimationFormat.JSON_EMOTECRAFT ); builder.beginTick = 0; builder.endTick = 1; builder.stopTick = 1; builder.isLooped = true; builder.returnTick = 0; builder.name = animName; String[] jointNames = data.jointNames(); Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); // Track which PlayerAnimator part names received actual animation data Set partsWithKeyframes = new HashSet<>(); for (int j = 0; j < data.jointCount(); j++) { String boneName = jointNames[j]; if (!GltfBoneMapper.isKnownBone(boneName)) continue; // Check if this joint has explicit animation data (not just rest pose fallback). // A bone counts as explicitly animated if it has rotation OR translation keyframes. boolean hasExplicitAnim = rawClip != null && ((j < rawClip.rotations().length && rawClip.rotations()[j] != null) || (rawClip.translations() != null && j < rawClip.translations().length && rawClip.translations()[j] != null)); Quaternionf animQ = getRawAnimQuaternion( rawClip, rawRestRotations, j ); Quaternionf restQ = rawRestRotations[j]; // delta_local = inverse(rest_q) * anim_q (in bone-local frame) Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); // Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest) Quaternionf deltaParent = new Quaternionf(restQ) .mul(deltaLocal) .mul(new Quaternionf(restQ).invert()); // Convert from glTF parent frame to MC model-def frame. // 180deg rotation around Z (X and Y differ): negate qx and qy. Quaternionf deltaQ = new Quaternionf(deltaParent); deltaQ.x = -deltaQ.x; deltaQ.y = -deltaQ.y; if (GltfBoneMapper.isLowerBone(boneName)) { convertLowerBone(builder, boneName, deltaQ); } else { convertUpperBone(builder, boneName, deltaQ); } // Record which PlayerAnimator part received data if (hasExplicitAnim) { String animPart = GltfBoneMapper.getAnimPartName(boneName); if (animPart != null) { partsWithKeyframes.add(animPart); } // For lower bones, the keyframe data goes to the upper bone's part if (GltfBoneMapper.isLowerBone(boneName)) { String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); if (upperBone != null) { String upperPart = GltfBoneMapper.getAnimPartName( upperBone ); if (upperPart != null) { partsWithKeyframes.add(upperPart); } } } } } // Selective: enable owned parts always, free parts only if they have keyframes enableSelectiveParts( builder, ownedParts, enabledParts, partsWithKeyframes ); KeyframeAnimation anim = builder.build(); LOGGER.debug( "[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})", animName, ownedParts, enabledParts, partsWithKeyframes ); return anim; } /** * Add keyframes for specific owned parts from a GLB animation clip to an existing builder. * *

Only writes keyframes for bones that map to a part in {@code ownedParts}. * Other bones are skipped entirely. This allows multiple items to contribute * to the same animation builder without overwriting each other's keyframes.

* * @param builder the shared animation builder to add keyframes to * @param data parsed glTF data * @param rawClip the raw animation clip, or null for rest pose * @param ownedParts parts this item exclusively owns (only these get keyframes) * @return set of part names that received actual keyframe data from the GLB */ public static Set addBonesToBuilder( KeyframeAnimation.AnimationBuilder builder, GltfData data, @Nullable GltfData.AnimationClip rawClip, Set ownedParts ) { String[] jointNames = data.jointNames(); Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); Set partsWithKeyframes = new HashSet<>(); for (int j = 0; j < data.jointCount(); j++) { String boneName = jointNames[j]; if (!GltfBoneMapper.isKnownBone(boneName)) continue; // Only process bones that belong to this item's owned parts String animPart = GltfBoneMapper.getAnimPartName(boneName); if (animPart == null || !ownedParts.contains(animPart)) continue; // For lower bones, check if the UPPER bone's part is owned // (lower bone keyframes go to the upper bone's StateCollection) if (GltfBoneMapper.isLowerBone(boneName)) { String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); if (upperBone != null) { String upperPart = GltfBoneMapper.getAnimPartName( upperBone ); if ( upperPart == null || !ownedParts.contains(upperPart) ) continue; } } boolean hasExplicitAnim = rawClip != null && ((j < rawClip.rotations().length && rawClip.rotations()[j] != null) || (rawClip.translations() != null && j < rawClip.translations().length && rawClip.translations()[j] != null)); Quaternionf animQ = getRawAnimQuaternion( rawClip, rawRestRotations, j ); Quaternionf restQ = rawRestRotations[j]; Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); Quaternionf deltaParent = new Quaternionf(restQ) .mul(deltaLocal) .mul(new Quaternionf(restQ).invert()); Quaternionf deltaQ = new Quaternionf(deltaParent); deltaQ.x = -deltaQ.x; deltaQ.y = -deltaQ.y; if (GltfBoneMapper.isLowerBone(boneName)) { convertLowerBone(builder, boneName, deltaQ); } else { convertUpperBone(builder, boneName, deltaQ); } if (hasExplicitAnim) { partsWithKeyframes.add(animPart); if (GltfBoneMapper.isLowerBone(boneName)) { String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); if (upperBone != null) { String upperPart = GltfBoneMapper.getAnimPartName( upperBone ); if (upperPart != null) partsWithKeyframes.add( upperPart ); } } } } return partsWithKeyframes; } /** * Convert an animation clip using skeleton data from a separate source. * *

This is useful when the animation clip is stored separately from the * skeleton (e.g., furniture seat animations where the Player_* armature's * clips are parsed into a separate map from the skeleton GltfData).

* *

The resulting animation has all parts fully enabled. Callers should * create a mutable copy and selectively disable parts as needed.

* * @param skeleton the GltfData providing rest pose, joint names, and joint count * @param clip the raw animation clip (in glTF space) to convert * @param animName debug name for the resulting animation * @return a static looping KeyframeAnimation with all parts enabled */ public static KeyframeAnimation convertWithSkeleton( GltfData skeleton, GltfData.AnimationClip clip, String animName ) { return convertClip(skeleton, clip, animName); } /** * Internal: convert a specific raw animation clip to a KeyframeAnimation. */ private static KeyframeAnimation convertClip( GltfData data, GltfData.AnimationClip rawClip, String animName ) { KeyframeAnimation.AnimationBuilder builder = new KeyframeAnimation.AnimationBuilder( AnimationFormat.JSON_EMOTECRAFT ); builder.beginTick = 0; builder.endTick = 1; builder.stopTick = 1; builder.isLooped = true; builder.returnTick = 0; builder.name = animName; String[] jointNames = data.jointNames(); Quaternionf[] rawRestRotations = data.rawGltfRestRotations(); for (int j = 0; j < data.jointCount(); j++) { String boneName = jointNames[j]; if (!GltfBoneMapper.isKnownBone(boneName)) continue; Quaternionf animQ = getRawAnimQuaternion( rawClip, rawRestRotations, j ); Quaternionf restQ = rawRestRotations[j]; // delta_local = inverse(rest_q) * anim_q (in bone-local frame) Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ); // Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest) // Simplifies algebraically to: animQ * inv(restQ) Quaternionf deltaParent = new Quaternionf(restQ) .mul(deltaLocal) .mul(new Quaternionf(restQ).invert()); // Convert from glTF parent frame to MC model-def frame. // 180° rotation around Z (X and Y differ): negate qx and qy. Quaternionf deltaQ = new Quaternionf(deltaParent); deltaQ.x = -deltaQ.x; deltaQ.y = -deltaQ.y; LOGGER.debug( String.format( "[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)", boneName, restQ.x, restQ.y, restQ.z, restQ.w, animQ.x, animQ.y, animQ.z, animQ.w, deltaQ.x, deltaQ.y, deltaQ.z, deltaQ.w ) ); if (GltfBoneMapper.isLowerBone(boneName)) { convertLowerBone(builder, boneName, deltaQ); } else { convertUpperBone(builder, boneName, deltaQ); } } builder.fullyEnableParts(); KeyframeAnimation anim = builder.build(); LOGGER.debug( "[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation", animName ); return anim; } /** * Get the raw animation quaternion for a joint from a specific clip. * Falls back to rest rotation if the clip is null or has no data for this joint. */ private static Quaternionf getRawAnimQuaternion( GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex ) { if ( rawClip != null && jointIndex < rawClip.rotations().length && rawClip.rotations()[jointIndex] != null ) { return rawClip.rotations()[jointIndex][0]; // first frame } return rawRestRotations[jointIndex]; // fallback to rest } private static void convertUpperBone( KeyframeAnimation.AnimationBuilder builder, String boneName, Quaternionf deltaQ ) { // Decompose delta quaternion to Euler ZYX // JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation // (the "ZYX" refers to rotation ORDER, not storage order) Vector3f euler = new Vector3f(); deltaQ.getEulerAnglesZYX(euler); float pitch = euler.x; // X rotation (pitch) float yaw = euler.y; // Y rotation (yaw) float roll = euler.z; // Z rotation (roll) LOGGER.debug( String.format( "[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°", boneName, Math.toDegrees(pitch), Math.toDegrees(yaw), Math.toDegrees(roll) ) ); // Get the StateCollection for this body part String animPart = GltfBoneMapper.getAnimPartName(boneName); if (animPart == null) return; KeyframeAnimation.StateCollection part = getPartByName( builder, animPart ); if (part == null) return; part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT); part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT); part.roll.addKeyFrame(0, roll, Ease.CONSTANT); } private static void convertLowerBone( KeyframeAnimation.AnimationBuilder builder, String boneName, Quaternionf deltaQ ) { // Extract bend angle and axis from the delta quaternion float angle = 2.0f * (float) Math.acos(Math.min(1.0, Math.abs(deltaQ.w))); // Determine bend direction from axis float bendDirection = 0.0f; if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) { bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x); } // Sign: if w is negative, the angle wraps if (deltaQ.w < 0) { angle = -angle; } LOGGER.debug( String.format( "[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°", boneName, Math.toDegrees(angle), Math.toDegrees(bendDirection) ) ); // Apply bend to the upper bone's StateCollection String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); if (upperBone == null) return; String animPart = GltfBoneMapper.getAnimPartName(upperBone); if (animPart == null) return; KeyframeAnimation.StateCollection part = getPartByName( builder, animPart ); if (part == null || !part.isBendable) return; part.bend.addKeyFrame(0, angle, Ease.CONSTANT); part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT); } private static KeyframeAnimation.StateCollection getPartByName( KeyframeAnimation.AnimationBuilder builder, String name ) { return switch (name) { case "head" -> builder.head; case "body" -> builder.body; case "rightArm" -> builder.rightArm; case "leftArm" -> builder.leftArm; case "rightLeg" -> builder.rightLeg; case "leftLeg" -> builder.leftLeg; default -> null; }; } /** * Enable parts selectively based on ownership and keyframe presence. * *
    *
  • Owned parts: always enabled (the item controls these bones)
  • *
  • Free parts WITH keyframes: enabled (the GLB has animation data for them)
  • *
  • Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)
  • *
  • Other items' parts: disabled (pass through to their own layer)
  • *
* * @param builder the animation builder with keyframes already added * @param ownedParts parts the item explicitly owns (always enabled) * @param enabledParts parts the item may animate (owned + free) * @param partsWithKeyframes parts that received actual animation data from the GLB */ private static void enableSelectiveParts( KeyframeAnimation.AnimationBuilder builder, Set ownedParts, Set enabledParts, Set partsWithKeyframes ) { String[] allParts = { "head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg", }; for (String partName : allParts) { KeyframeAnimation.StateCollection part = getPartByName( builder, partName ); if (part != null) { if (ownedParts.contains(partName)) { // Always enable owned parts — the item controls these bones part.fullyEnablePart(false); } else if ( enabledParts.contains(partName) && partsWithKeyframes.contains(partName) ) { // Free part WITH keyframes: enable so the GLB animation drives it part.fullyEnablePart(false); } else { // Other item's part, or free part without keyframes: disable. // Disabled parts pass through to the lower-priority context layer. part.setEnabled(false); } } } } }