copy first keyframe button
This commit is contained in:
+58
-9
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user