Files
TiedUp-/src/main/java/com/tiedup/remake/client/gltf/GltfPoseConverter.java
NotEvil 11188bc621 Refactor V2 animation, furniture, and GLTF rendering
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.
2026-04-18 17:34:03 +02:00

754 lines
29 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 static final int TICKS_PER_SECOND = 20;
private static final Ease DEFAULT_EASE = Ease.LINEAR;
private GltfPoseConverter() {}
/**
* Compute the end tick (inclusive) of a clip's keyframe timeline, relative
* to the clip's first timestamp. Returns 1 for null or empty clips (minimum
* valid builder endTick). glTF timestamps are in seconds; MC ticks are 20 Hz.
*
* <p>The baseline subtraction ensures clips authored with an NLA onset
* ({@code timestamps[0] > 0}) don't leave tick range {@code [0, firstTick)}
* undefined on each loop — the clip is always timeline-normalized to start
* at tick 0.</p>
*/
public static int computeEndTick(@Nullable GltfData.AnimationClip clip) {
if (
clip == null ||
clip.frameCount() == 0 ||
clip.timestamps().length == 0
) {
return 1;
}
float[] times = clip.timestamps();
int lastIdx = Math.min(times.length - 1, clip.frameCount() - 1);
float baseline = times[0];
return Math.max(
1,
Math.round((times[lastIdx] - baseline) * TICKS_PER_SECOND)
);
}
/**
* Convert a frame index to an MC tick based on the clip's timestamps,
* relative to {@code baselineSeconds} (typically {@code timestamps[0]}).
*/
private static int frameToTick(
@Nullable GltfData.AnimationClip clip,
int frameIndex,
float baselineSeconds
) {
if (clip == null) return 0;
float[] times = clip.timestamps();
if (frameIndex >= times.length) return 0;
return Math.round((times[frameIndex] - baselineSeconds) * TICKS_PER_SECOND);
}
/** Return the timestamp baseline for the clip, or 0 if absent. */
private static float timelineBaseline(@Nullable GltfData.AnimationClip clip) {
if (clip == null || clip.timestamps().length == 0) return 0f;
return clip.timestamps()[0];
}
/**
* 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,
@Nullable GltfData.AnimationClip rawClip,
String animName,
Set<String> ownedParts,
Set<String> enabledParts
) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
builder.beginTick = 0;
builder.endTick = endTick;
builder.stopTick = endTick;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
// Track which PlayerAnimator part names received actual animation data.
// Joint-level; not frame-dependent — we detect once on frame 0.
Set<String> partsWithKeyframes = new HashSet<>();
// Tick deduplication: MC runs at 20 Hz. Source clips authored at higher
// rates (24/30/60 FPS Blender) produce multiple frames that round to the
// same tick; emit once per unique tick (keep the first) so artists see
// deterministic behavior rather than relying on PlayerAnimator's "last
// inserted wins" semantic. ARTIST_GUIDE: author at 20 FPS for 1:1.
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */f == 0 ? partsWithKeyframes : null
);
lastTick = tick;
}
// Selective: enable owned parts always, free parts only for "Full" animations
// that explicitly opt into full-body control.
enableSelectiveParts(
builder,
ownedParts,
enabledParts,
partsWithKeyframes,
animName
);
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})",
animName,
frameCount,
endTick,
ownedParts,
enabledParts,
partsWithKeyframes
);
return anim;
}
/**
* Apply a single frame's delta rotations for every known bone to the builder,
* writing one keyframe per bone at {@code tick}.
*
* @param ownedFilter if non-null, only bones whose animPart is in this
* set are written (shared-builder multi-item path)
* @param keyframeCollector if non-null, parts that have explicit rotation or
* translation channels are added to this set
*/
private static void applyFrameToBuilder(
KeyframeAnimation.AnimationBuilder builder,
GltfData data,
@Nullable GltfData.AnimationClip rawClip,
int frameIndex,
int tick,
Ease ease,
@Nullable Set<String> ownedFilter,
@Nullable Set<String> keyframeCollector
) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Two known bones can map to the same PlayerAnimator part (e.g.
// `body` + `torso` → "body"). Both would write to the same
// StateCollection and the second write silently wins; instead,
// first-in-array-order wins and subsequent collisions are skipped.
// Lower bones don't conflict with upper bones (separate axis).
Set<String> claimedUpperParts = new java.util.HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) continue;
boolean isLower = GltfBoneMapper.isLowerBone(boneName);
// Apply ownedFilter BEFORE claiming the slot: a bone that this item
// doesn't own must not reserve the upper-part slot, otherwise a
// later owned bone mapping to the same slot gets spuriously
// rejected by the collision check below.
if (ownedFilter != null) {
if (!ownedFilter.contains(animPart)) continue;
// For lower bones, also require the upper bone's part to be owned.
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upper);
if (
upperPart == null ||
!ownedFilter.contains(upperPart)
) continue;
}
}
}
if (!isLower && !claimedUpperParts.add(animPart)) {
// Another upper bone already claimed this PlayerAnimator part.
// Skip the duplicate write so HashMap iteration order can't
// silently flip which bone drives the pose.
if (frameIndex == 0) {
LOGGER.warn(
"[GltfPipeline] Bone '{}' maps to PlayerAnimator part '{}' already written by an earlier bone — ignoring. Use only one of them in the GLB.",
boneName,
animPart
);
}
continue;
}
Quaternionf animQ = getRawAnimQuaternion(
rawClip,
rawRestRotations,
j,
frameIndex
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (isLower) {
convertLowerBone(builder, boneName, deltaQ, tick, ease);
} else {
convertUpperBone(builder, boneName, deltaQ, tick, ease);
}
if (keyframeCollector != null) {
// Translation-only channels count as "explicit": a pure-
// translation animation (e.g. a rigid-body bounce) still
// feeds keyframes to PlayerAnimator, so its part must be
// claimed for composite merging.
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
if (hasExplicitAnim) {
keyframeCollector.add(animPart);
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upper
);
if (upperPart != null) keyframeCollector.add(
upperPart
);
}
}
}
}
}
}
/**
* 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
) {
Set<String> partsWithKeyframes = new HashSet<>();
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
f,
tick,
DEFAULT_EASE,
ownedParts,
f == 0 ? partsWithKeyframes : null
);
lastTick = tick;
}
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,
@Nullable GltfData.AnimationClip rawClip,
String animName
) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
builder.beginTick = 0;
builder.endTick = endTick;
builder.stopTick = endTick;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */null
);
lastTick = tick;
}
builder.fullyEnableParts();
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
animName,
frameCount,
endTick
);
return anim;
}
/**
* Get the raw animation quaternion for a joint at a specific frame.
* Falls back to rest rotation if the clip is null, has no data for this joint,
* or has an empty channel. Clamps frameIndex to the last available frame if
* the joint's channel is shorter than the shared timestamps array.
*/
private static Quaternionf getRawAnimQuaternion(
@Nullable GltfData.AnimationClip rawClip,
Quaternionf[] rawRestRotations,
int jointIndex,
int frameIndex
) {
if (
rawClip != null &&
jointIndex < rawClip.rotations().length &&
rawClip.rotations()[jointIndex] != null
) {
Quaternionf[] channel = rawClip.rotations()[jointIndex];
if (channel.length > 0) {
int safeFrame = Math.min(frameIndex, channel.length - 1);
return channel[safeFrame];
}
}
// Defensive: under a well-formed GLB, jointCount == restRotations.length
// (guaranteed by the parser). This guard keeps us from AIOOBE-ing if
// that invariant is ever broken by a future parser path.
if (jointIndex >= rawRestRotations.length) {
return new Quaternionf();
}
return rawRestRotations[jointIndex];
}
private static void convertUpperBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName,
Quaternionf deltaQ,
int tick,
Ease ease
) {
// "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
// Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
// middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
Vector3f euler = new Vector3f();
deltaQ.getEulerAnglesZYX(euler);
float pitch = euler.x;
float yaw = euler.y;
float roll = euler.z;
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) return;
KeyframeAnimation.StateCollection part = getPartByName(
builder,
animPart
);
if (part == null) return;
part.pitch.addKeyFrame(tick, pitch, ease);
part.yaw.addKeyFrame(tick, yaw, ease);
part.roll.addKeyFrame(tick, roll, ease);
}
private static void convertLowerBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName,
Quaternionf deltaQ,
int tick,
Ease ease
) {
// Canonicalize q: q and -q represent the same rotation. Always pick the
// hemisphere with w >= 0 so consecutive frames don't pop across the
// double-cover boundary when interpolating.
float qx = deltaQ.x;
float qy = deltaQ.y;
float qz = deltaQ.z;
float qw = deltaQ.w;
if (qw < 0) {
qx = -qx;
qy = -qy;
qz = -qz;
qw = -qw;
}
// Now qw is in [0, 1]. Rotation angle = 2 * acos(qw), in [0, π].
float angle = 2.0f * (float) Math.acos(Math.min(1.0f, qw));
float bendDirection = 0.0f;
if (qx * qx + qz * qz > 0.001f) {
bendDirection = (float) Math.atan2(qz, qx);
}
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(tick, angle, ease);
part.bendDirection.addKeyFrame(tick, bendDirection, ease);
}
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 AND "Full" animation: enabled (explicit opt-in to full-body)</li>
* <li>Free parts without "Full" prefix: disabled (prevents accidental bone hijacking)</li>
* <li>Other items' parts: disabled (pass through to their own layer)</li>
* </ul>
*
* <p>The "Full" prefix convention (FullIdle, FullStruggle, FullWalk) is the artist's
* explicit declaration that this animation is designed to control the entire body,
* not just the item's owned regions. Without this prefix, free bones are never enabled,
* even if the GLB contains keyframes for them. This prevents accidental bone hijacking
* when an artist keyframes all bones in Blender by default.</p>
*
* @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
* @param animName resolved animation name (checked for "Full" prefix)
*/
private static void enableSelectiveParts(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts,
Set<String> enabledParts,
Set<String> partsWithKeyframes,
String animName
) {
boolean isFullBodyAnimation = isFullBodyAnimName(animName);
boolean allowFreeHead = isFullHeadAnimName(animName);
enableSelectivePartsCore(
builder,
ownedParts,
enabledParts,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
/**
* Check whether a resolved-and-prefixed animation name (e.g. {@code "gltf_FullStruggle"})
* declares opt-in to full-body free-bone animation. See the "Full" prefix
* convention in {@link #enableSelectiveParts}.
*/
public static boolean isFullBodyAnimName(@Nullable String animName) {
return animName != null && animName.startsWith("gltf_Full");
}
/**
* Check whether a resolved-and-prefixed animation name opts in to head
* animation as a free bone (e.g. {@code "gltf_FullHeadStruggle"}). Head is
* protected by default to preserve vanilla head-tracking on bondage items
* that don't specifically want to animate it.
*/
public static boolean isFullHeadAnimName(@Nullable String animName) {
return isFullBodyAnimName(animName) &&
animName.startsWith("gltf_FullHead");
}
/**
* Composite variant of {@link #enableSelectiveParts} used by the multi-item
* path. Callers (e.g. {@code GltfAnimationApplier.applyMultiItemV2Animation})
* compute the three aggregates themselves: {@code allOwnedParts} is the
* union of owned regions across all items, {@code partsWithKeyframes} is
* the union of keyframe parts returned by each {@link #addBonesToBuilder}
* call, and the two Full/FullHead flags should be true if ANY item in the
* composite resolved to a {@code FullX}/{@code FullHeadX} animation name.
*/
public static void enableSelectivePartsComposite(
KeyframeAnimation.AnimationBuilder builder,
Set<String> allOwnedParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
// In the composite path every animation part is implicitly in
// enabledParts — if a FullX animation has keyframes for it, we want it
// enabled. Pass ALL_PARTS as the enabled set so the single-item
// opt-out path is a no-op.
enableSelectivePartsCore(
builder,
allOwnedParts,
ALL_PARTS_SET,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
private static final Set<String> ALL_PARTS_SET = Set.of(
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg"
);
private static void enableSelectivePartsCore(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts,
Set<String> enabledParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
for (String partName : ALL_PARTS_SET) {
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 (
isFullBodyAnimation &&
enabledParts.contains(partName) &&
partsWithKeyframes.contains(partName) &&
(!"head".equals(partName) || allowFreeHead)
) {
// Full-body animation: free part WITH keyframes — enable.
// The "Full" prefix is the artist's explicit opt-in to animate
// bones outside their declared regions.
// Head is protected by default (preserves vanilla head tracking).
// Use "Head" in the animation name (e.g., FullHeadStruggle) to
// explicitly opt-in to head control for that animation.
part.fullyEnablePart(false);
} else {
// Non-Full animation, other item's part, or free part without keyframes.
// Disabled parts pass through to the lower-priority context layer.
part.setEnabled(false);
}
}
}
}
}