diff --git a/AnimationDirector.js b/AnimationDirector.js index 6439de3..c267a33 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -20,6 +20,7 @@ let automaticImportAction; let packFoldersAction; let previousStagePoseAction; + let loopPoseAction; const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'add_animation', 'remove_animation']; const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews'; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; @@ -2501,21 +2502,54 @@ } } + function loopPoseUnavailableReason() { + if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return 'Open a GeckoLib animation project first.'; + const animation = Animation.selected; + if (!animation) return 'Select an animation clip first.'; + if (!Number.isFinite(animation.length) || animation.length <= 0) return 'Set a positive animation clip length first.'; + return null; + } + + function refreshLoopPoseButton() { + if (!loopPoseAction) return; + const reason = loopPoseUnavailableReason(); + const description = reason || 'Copy the first pose of all actors to the clip endpoint for looping. Existing endpoint transforms are replaced.'; + loopPoseAction.description = description; + for (const node of loopPoseAction.nodes || []) { + node.classList.toggle('disabled', !!reason); + node.setAttribute('aria-disabled', String(!!reason)); + const label = node.querySelector('.tooltip_description'); + if (label) label.textContent = description; + } + } + + function copyFirstPoseToEnd() { + const reason = loopPoseUnavailableReason(); + if (reason) { Blockbench.showQuickMessage(reason, 2500); return; } + const target = Animation.selected; + copyActorPose(target, 0, target, target.length, 'Copy first pose to clip end', true); + } + function copyPreviousStagePose() { const { source, reason } = previousStagePoseSource(); if (reason) { Blockbench.showQuickMessage(reason, 2500); return; } - const target = Animation.selected, poses = []; + copyActorPose(source, source.length, Animation.selected, 0, 'Copy previous stage pose'); + } + + function copyActorPose(source, sourceTime, target, targetTime, label, keyedChannelsOnly = false) { + const poses = []; const originalTime = Timeline.time; try { - Timeline.time = source.length; + Timeline.time = sourceTime; for (const group of Group.all) { if (readActorIndexFromName(group.name) === null || isBlockRequirementGuide(group)) continue; const animator = source.animators[group.uuid]; Animator.resetLastValues(); for (const channel of ['position', 'rotation', 'scale']) { + if (keyedChannelsOnly && !animator?.[channel]?.length) continue; const value = animator?.[channel]?.length ? animator.interpolate(channel, false) : null; const values = value || (channel === 'scale' ? [1, 1, 1] : [0, 0, 0]); - if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at the previous stage endpoint.`); + if (values.length !== 3 || !values.every(Number.isFinite)) throw new Error(`Cannot evaluate ${group.name} ${channel} at time ${sourceTime}.`); poses.push({ group, channel, values }); } } @@ -2523,20 +2557,21 @@ Timeline.time = originalTime; Animator.resetLastValues(); } - if (!poses.length) { Blockbench.showQuickMessage('There are no actor bones to copy.', 2500); return; } - const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === 0)); + if (!poses.length) { Blockbench.showQuickMessage('There are no actor transforms to copy.', 2500); return; } + const keyframes = poses.flatMap(({ group, channel }) => (target.animators[group.uuid]?.[channel] || []).filter((frame) => frame.time === targetTime)); Undo.initEdit({ animations: [target], keyframes }); for (const { group, channel, values } of poses) { const animator = target.getBoneAnimator(group); - const frame = animator[channel].find((frame) => frame.time === 0); + const frame = animator[channel].find((frame) => frame.time === targetTime); const data = { interpolation: 'linear', uniform: false, data_points: [{ x: values[0], y: values[1], z: values[2] }] }; if (frame) frame.extend(data); - else keyframes.push(animator.createKeyframe(data, 0, channel, false, false)); + // Preserve the exact clip endpoint, even when it falls between timeline grid steps. + else keyframes.push(animator.addKeyframe({ ...data, channel, time: targetTime })); } - Undo.finishEdit('Copy previous stage pose', { animations: [target], keyframes }); + Undo.finishEdit(label, { animations: [target], keyframes }); Animator.preview(); if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection(); - Blockbench.showQuickMessage('Copied previous stage pose to time 0.', 2500); + Blockbench.showQuickMessage(`Copied actor pose to time ${targetTime}.`, 2500); } function cleanSoundPreviewFields(clip) { @@ -5795,14 +5830,26 @@ You can import or export complete AFW animation IDs as one file containing all s catch (error) { Blockbench.showMessageBox({ title: 'Copy Previous Stage Pose', message: error.message }); } }, }); + loopPoseAction = new Action('animationdirector_first_pose_to_end', { + name: 'Copy First Pose to End', icon: 'step_over', color: '#b8a6cc', category: 'animation', + description: 'Copy the first actor pose to the clip endpoint for looping.', + click() { + try { copyFirstPoseToEnd(); } + catch (error) { Blockbench.showMessageBox({ title: 'Copy First Pose to End', message: error.message }); } + }, + }); if (typeof Toolbars !== 'undefined' && Toolbars.timeline) { const toolbar = Toolbars.timeline; const marker = toolbar.children.findIndex((item) => item === 'add_marker' || item?.id === 'add_marker'); toolbar.add(previousStagePoseAction, marker >= 0 ? marker + 1 : 0); + toolbar.add(loopPoseAction, toolbar.children.indexOf(previousStagePoseAction) + 1); } for (const event of POSE_BUTTON_EVENTS) Blockbench.on(event, refreshPreviousStagePoseButton); previousStagePoseAction.node?.addEventListener('mouseenter', refreshPreviousStagePoseButton); refreshPreviousStagePoseButton(); + for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.on(event, refreshLoopPoseButton); + loopPoseAction.node?.addEventListener('mouseenter', refreshLoopPoseButton); + refreshLoopPoseButton(); if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') { soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, { exposed: false, condition: (point) => point.keyframe.channel === 'sound', @@ -6026,6 +6073,8 @@ You can import or export complete AFW animation IDs as one file containing all s MenuBar.update(); }, onunload() { + for (const event of [...POSE_BUTTON_EVENTS, 'update_animation_length']) Blockbench.removeListener(event, refreshLoopPoseButton); + loopPoseAction?.delete(); loopPoseAction = undefined; for (const event of POSE_BUTTON_EVENTS) Blockbench.removeListener(event, refreshPreviousStagePoseButton); previousStagePoseAction?.delete(); previousStagePoseAction = undefined; Blockbench.removeListener('timeline_play', figuraSoundPlayListener); diff --git a/test/previous-stage-pose.test.js b/test/previous-stage-pose.test.js new file mode 100644 index 0000000..09efbcb --- /dev/null +++ b/test/previous-stage-pose.test.js @@ -0,0 +1,134 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const test = require('node:test'); + +function harness() { + const context = { + Plugin: { register() {} }, Format: { id: 'geckolib_model' }, Project: {}, + Animation: { all: [], selected: null }, Group: { all: [] }, Timeline: { time: 0.7 }, + Animator: { resetLastValues() {}, preview() {} }, + edits: [], Undo: { initEdit(edit) { context.edits.push(edit); }, finishEdit(label) { context.label = label; } }, + Blockbench: { showQuickMessage(message) { context.message = message; } }, console, + }; + const code = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8') + .replace(' Plugin.register(PLUGIN_ID, {', ' globalThis.api = { previousStagePoseSource, copyPreviousStagePose, copyFirstPoseToEnd, loopPoseUnavailableReason, refreshPreviousStagePoseButton, setButton(button) { previousStagePoseAction = button; } };\n Plugin.register(PLUGIN_ID, {'); + vm.runInNewContext(code, context); + return context; +} + +function animation(name, length = 2) { + return { name, length, animators: {}, getBoneAnimator(group) { + return this.animators[group.uuid] ||= animator(); + } }; +} + +function animator() { + return { position: [], rotation: [], scale: [], addKeyframe(data) { + return this.createKeyframe(data, data.time, data.channel); + }, createKeyframe(data, time, channel) { + const frame = { ...data, time, channel, extend(data) { Object.assign(this, data); } }; + this[channel].push(frame); return frame; + } }; +} + +test('previous pose button stays visible and explains why it cannot be used', () => { + const h = harness(), states = [], label = {}; + const button = { nodes: [{ classList: { toggle(name, value) { states.push(value); } }, setAttribute() {}, querySelector() { return label; } }] }; + h.api.setButton(button); + h.api.refreshPreviousStagePoseButton(); + assert.match(label.textContent, /Select an animation stage/); + h.Animation.selected = animation('example.p1'); h.Animation.all = [h.Animation.selected]; + h.api.refreshPreviousStagePoseButton(); + assert.match(label.textContent, /no previous stage/); + assert.deepEqual(states, [true, true]); +}); + +test('previous stage uses numeric ordering, same animation ID and saved reused stages', () => { + const h = harness(), first = animation('example.p1'), current = animation('example.p3'); + h.Animation.selected = current; + h.Animation.all = [animation('unrelated.p2'), current, first]; + h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 1 }, { stage: 2, use_stage: 1 }, { stage: 3 }] } }); + assert.equal(h.api.previousStagePoseSource().source, first); + assert.equal(h.api.previousStagePoseSource().previous, 2); + h.Project.animationdirector_animdef_definitions = JSON.stringify({ example: { stages: [{ stage: 2, use_stage: 9 }] } }); + assert.match(h.api.previousStagePoseSource().reason, /missing animation p9/); +}); + +test('copies evaluated endpoint for all actors, resets absent channels and preserves later frames and effects', () => { + const h = harness(), source = animation('example.p1'), target = animation('example.p2'); + h.Animation.selected = target; h.Animation.all = [target, source]; + const groups = [{ uuid: 'a', name: 'actor1_body' }, { uuid: 'b', name: 'actor2_head' }, { uuid: 'guide', name: '__ad_block_requirement_x' }]; + h.Group.all = groups; + source.animators.a = animator(); + source.animators.a.position = [{ time: 1 }]; + source.animators.a.interpolate = (channel, allowExpression) => { + assert.equal(h.Timeline.time, 2); assert.equal(allowExpression, false); + return [4, 5, 6]; + }; + target.animators.a = animator(); + const initial = target.animators.a.createKeyframe({ data_points: [{ x: 99 }] }, 0, 'position'); + const later = target.animators.a.createKeyframe({ data_points: [{ x: 77 }] }, 1, 'position'); + target.animators.effects = { sound: [{ time: 0, effect: 'test' }] }; + h.api.copyPreviousStagePose(); + assert.equal(h.Timeline.time, 0.7); + assert.equal(initial.data_points[0].x, 4); + assert.equal(later.data_points[0].x, 77); + assert.equal(target.animators.b.scale[0].data_points[0].x, 1); + assert.equal(target.animators.b.rotation[0].data_points[0].x, 0); + assert.equal(target.animators.effects.sound[0].effect, 'test'); + assert.equal(target.animators.guide, undefined); + assert.equal(h.edits.length, 1); +}); + +test('failed endpoint evaluation restores playhead and leaves target unmodified', () => { + const h = harness(), source = animation('example.p1'), target = animation('example.p2'); + h.Animation.selected = target; h.Animation.all = [source, target]; + h.Group.all = [{ uuid: 'a', name: 'actor1_body' }]; + source.animators.a = { position: [{}], interpolate() { return [NaN, 0, 0]; } }; + assert.throws(() => h.api.copyPreviousStagePose(), /Cannot evaluate/); + assert.equal(h.Timeline.time, 0.7); + assert.equal(h.edits.length, 0); + assert.deepEqual(target.animators, {}); +}); + +test('loop pose copies time zero to exact endpoint without modifying intermediate keys or source pose', () => { + const h = harness(), clip = animation('example.p1', 2.1234); + h.Animation.selected = clip; h.Animation.all = [clip]; + const group = { uuid: 'a', name: 'actor1_body' }; + h.Group.all = [group, { uuid: 'empty', name: 'actor2_head' }]; + const bone = clip.animators.a = animator(); + const first = bone.createKeyframe({ data_points: [{ x: 5, y: 6, z: 7 }] }, 0, 'position'); + const middle = bone.createKeyframe({ data_points: [{ x: 99 }] }, 1, 'position'); + const last = bone.createKeyframe({ data_points: [{ x: 88 }] }, clip.length, 'position'); + bone.interpolate = () => { assert.equal(h.Timeline.time, 0); return [5, 6, 7]; }; + h.api.copyFirstPoseToEnd(); + assert.equal(first.data_points[0].x, 5); + assert.equal(middle.data_points[0].x, 99); + assert.equal(last.data_points[0].x, 5); + assert.equal(bone.scale.length, 0); + assert.equal(bone.rotation.length, 0); + assert.equal(clip.animators.empty, undefined); + assert.equal(clip.length, 2.1234); + assert.equal(h.Timeline.time, 0.7); + assert.equal(h.edits.length, 1); +}); + +test('loop pose on an unkeyed clip does not create animators or an undo edit', () => { + const h = harness(), clip = animation('example.p1'); + h.Animation.selected = clip; h.Animation.all = [clip]; + h.Group.all = [{ uuid: 'a', name: 'actor1_body' }]; + h.api.copyFirstPoseToEnd(); + assert.deepEqual(clip.animators, {}); + assert.equal(h.edits.length, 0); +}); + +test('loop pose is unavailable without a clip or with a zero length', () => { + const h = harness(); + assert.match(h.api.loopPoseUnavailableReason(), /Select an animation/); + h.Animation.selected = animation('example.p1', 0); + h.api.copyFirstPoseToEnd(); + assert.match(h.message, /positive animation clip length/); + assert.equal(h.edits.length, 0); +});