Strip all Phase references, TODO/FUTURE roadmap notes, and internal planning comments from the codebase. Run Prettier for consistent formatting across all Java files.
605 lines
22 KiB
Java
605 lines
22 KiB
Java
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.
|
|
* <p>
|
|
* 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.
|
|
* <p>
|
|
* 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.
|
|
* <p>
|
|
* 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.
|
|
*
|
|
* <p>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).</p>
|
|
*
|
|
* @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<String> ownedParts,
|
|
Set<String> 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.
|
|
*
|
|
* <p>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.</p>
|
|
*
|
|
* @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<String> ownedParts,
|
|
Set<String> 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<String> 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.
|
|
*
|
|
* <p>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.</p>
|
|
*
|
|
* @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<String> addBonesToBuilder(
|
|
KeyframeAnimation.AnimationBuilder builder,
|
|
GltfData data,
|
|
@Nullable GltfData.AnimationClip rawClip,
|
|
Set<String> ownedParts
|
|
) {
|
|
String[] jointNames = data.jointNames();
|
|
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
|
|
Set<String> 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.
|
|
*
|
|
* <p>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).</p>
|
|
*
|
|
* <p>The resulting animation has all parts fully enabled. Callers should
|
|
* create a mutable copy and selectively disable parts as needed.</p>
|
|
*
|
|
* @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.
|
|
*
|
|
* <ul>
|
|
* <li>Owned parts: always enabled (the item controls these bones)</li>
|
|
* <li>Free parts WITH keyframes: enabled (the GLB has animation data for them)</li>
|
|
* <li>Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)</li>
|
|
* <li>Other items' parts: disabled (pass through to their own layer)</li>
|
|
* </ul>
|
|
*
|
|
* @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<String> ownedParts,
|
|
Set<String> enabledParts,
|
|
Set<String> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|