copy first keyframe button
This commit is contained in:
@@ -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