From 94b3145254caa98e09caeb1636f78f64ed10cfc2 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 13:19:39 +0200 Subject: [PATCH] copy last stage button --- AnimationDirector.js | 108 +++++++++++++++++++++++++++++--- test/block-requirements.test.js | 11 ++-- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index 40e40fb..6439de3 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -19,6 +19,8 @@ let importAnimationDefinitionAction; let automaticImportAction; let packFoldersAction; + let previousStagePoseAction; + 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' }; let propAction, propProjectProperty, propDialog, propListener; @@ -2467,6 +2469,76 @@ return { groups, ignored }; } + function previousStagePoseSource() { + if (typeof Format === 'undefined' || Format.id !== 'geckolib_model') return { reason: 'Open a GeckoLib animation project first.' }; + const current = parseAfwStageAnimationName(Animation.selected); + if (!current) return { reason: 'Select an animation stage named animationID.pN.' }; + const stages = getSavedAnimdef(current.id).stages || []; + const candidates = [...Animation.all.map(parseAfwStageAnimationName).filter((stage) => stage?.id === current.id), + ...stages.map((stage) => ({ stageNumber: stage.stage }))].filter((stage) => stage.stageNumber < current.stageNumber); + if (!candidates.length) return { reason: 'This animation has no previous stage.' }; + const previous = Math.max(...candidates.map((stage) => stage.stageNumber)); + const playback = stages.find((stage) => stage.stage === previous)?.use_stage ?? previous; + const matches = Animation.all.filter((animation) => { + const stage = parseAfwStageAnimationName(animation); + return stage?.id === current.id && stage.stageNumber === playback; + }); + if (matches.length !== 1) return { reason: matches.length ? 'The previous stage has duplicate animations.' : `Previous stage p${previous} uses missing animation p${playback}.` }; + if (matches[0] === Animation.selected) return { reason: 'The previous stage reuses the current animation.' }; + return { source: matches[0], previous }; + } + + function refreshPreviousStagePoseButton() { + if (!previousStagePoseAction) return; + const { reason } = previousStagePoseSource(); + const description = reason || 'Copy the final transforms of all actors from the previous stage into time 0. Existing time-0 transforms are replaced.'; + previousStagePoseAction.description = description; + for (const node of previousStagePoseAction.nodes || []) { + node.classList.toggle('disabled', !!reason); + node.setAttribute('aria-disabled', String(!!reason)); + const label = node.querySelector('.tooltip_description'); + if (label) label.textContent = description; + } + } + + function copyPreviousStagePose() { + const { source, reason } = previousStagePoseSource(); + if (reason) { Blockbench.showQuickMessage(reason, 2500); return; } + const target = Animation.selected, poses = []; + const originalTime = Timeline.time; + try { + Timeline.time = source.length; + 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']) { + 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.`); + poses.push({ group, channel, values }); + } + } + } finally { + 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)); + 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 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)); + } + Undo.finishEdit('Copy previous stage pose', { animations: [target], keyframes }); + Animator.preview(); + if (typeof updateKeyframeSelection === 'function') updateKeyframeSelection(); + Blockbench.showQuickMessage('Copied previous stage pose to time 0.', 2500); + } + function cleanSoundPreviewFields(clip) { for (const cues of Object.values(clip?.sound_effects || {})) { for (const cue of Array.isArray(cues) ? cues : [cues]) { @@ -5715,6 +5787,22 @@ You can import or export complete AFW animation IDs as one file containing all s version: '2.0.0', variant: 'both', onload() { + previousStagePoseAction = new Action('animationdirector_previous_stage_pose', { + name: 'Copy Previous Stage Pose', icon: 'move_down', color: '#b8a6cc', category: 'animation', + description: 'Copy the final actor pose of the previous stage into time 0.', + click() { + try { copyPreviousStagePose(); } + catch (error) { Blockbench.showMessageBox({ title: 'Copy Previous Stage Pose', 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); + } + for (const event of POSE_BUTTON_EVENTS) Blockbench.on(event, refreshPreviousStagePoseButton); + previousStagePoseAction.node?.addEventListener('mouseenter', refreshPreviousStagePoseButton); + refreshPreviousStagePoseButton(); if (typeof Property === 'function' && typeof KeyframeDataPoint !== 'undefined') { soundPreviewProperty = new Property(KeyframeDataPoint, 'string', SOUND_PREVIEW_PROPERTY, { exposed: false, condition: (point) => point.keyframe.channel === 'sound', @@ -5833,7 +5921,7 @@ You can import or export complete AFW animation IDs as one file containing all s replaceActorAction = new Action('replace_actor_gecko_json', { name: 'Replace Actor Model', description: 'Replace one existing actor model while preserving its actorN_ prefix, animations, and preferences.', - icon: 'published_with_changes', + icon: 'swap_horiz', click: replaceActorGeckoJson, }); @@ -5847,7 +5935,7 @@ You can import or export complete AFW animation IDs as one file containing all s actorPreferencesAction = new Action('edit_actor_preferences', { name: 'Actor Preferences', description: 'Set project-specific export names, preview textures, and animation-definition metadata for each actor.', - icon: 'badge', + icon: 'psychology', click: editActorPreferences, }); @@ -5882,11 +5970,11 @@ You can import or export complete AFW animation IDs as one file containing all s importAnimationDefinitionAction = new Action('import_afw_animation_definition', { name: 'Import Animation Definition', description: 'Load generator fields and block requirement guides from an existing animdef JSON.', - icon: 'file_open', + icon: 'assignment_add', click: importAnimationDefinition, }); automaticImportAction = new Action('animationdirector_automatic_import', { - name: 'Automatically Import Animation', icon: 'auto_awesome', + name: 'Automatically Import Animation', icon: 'add_box', description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.', click: automaticallyImportAnimation, }); @@ -5912,14 +6000,18 @@ You can import or export complete AFW animation IDs as one file containing all s icon: 'movie', children: [ automaticImportAction, - { id: 'animationdirector_manual_import', name: 'Manual Import', icon: 'file_open', children: [ - importAction, importConjoinedAnimationAction, importAnimationDefinitionAction, packFoldersAction, - ] }, + importAction, + importConjoinedAnimationAction, + importAnimationDefinitionAction, + packFoldersAction, + '_', actorPreferencesAction, replaceActorAction, + '_', blockRequirementAction, propAction, soundLibraryAction, + '_', createAnimationDefinitionAction, exportConjoinedAction, ], @@ -5934,6 +6026,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) Blockbench.removeListener(event, refreshPreviousStagePoseButton); + previousStagePoseAction?.delete(); previousStagePoseAction = undefined; Blockbench.removeListener('timeline_play', figuraSoundPlayListener); removeFiguraSoundCompat(); for (const event of SOUND_EVENTS) Blockbench.removeListener(event, soundLibraryListener); diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js index 836fb9e..610f929 100644 --- a/test/block-requirements.test.js +++ b/test/block-requirements.test.js @@ -105,12 +105,11 @@ test('animation menu orders import and creation actions and has no deprecated sp const h = harness(); h.context.registration.onload(); const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims'); - assert.deepEqual(Array.from(menu.children, (action) => action.name), [ - 'Automatically Import Animation', 'Manual Import', 'Actor Preferences', - 'Replace Actor Model', 'Block Requirements', 'Item Props', 'NoN Sound Library', 'Create Animation Definition', 'Export Animations', - ]); - assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [ - 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders', + assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [ + 'Automatically Import Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders', + '_', 'Actor Preferences', 'Replace Actor Model', + '_', 'Block Requirements', 'Item Props', 'NoN Sound Library', + '_', 'Create Animation Definition', 'Export Animations', ]); const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8'); assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);