From f1f597dcca5c6def2113a6d9f45bb5d6490b9bab Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Mon, 28 Sep 2026 18:40:30 +0200 Subject: [PATCH] added way of replacing actor models --- AnimationDirector.js | 170 ++++++++++++++++++++++++++++++++- test/model-replacement.test.js | 59 ++++++++++++ 2 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 test/model-replacement.test.js diff --git a/AnimationDirector.js b/AnimationDirector.js index 99c0aca..4a7d0b5 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -17,7 +17,7 @@ other: 'Other', }; - let importAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; + let importAction, replaceActorAction, importConjoinedAnimationAction, importSplitAnimationAction, exportSelectedAction, exportAllAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty; let actorTexturePreviewProjectListener; const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path']; @@ -554,7 +554,71 @@ // ---------------------------- // Import Actor Geometry // ---------------------------- - function importGeometryIntoCurrentProject(geo, actorIndex) { + function removeActorGeometry(actorIndex) { + const prefix = `actor${actorIndex}_`; + const roots = Group.all.filter((group) => { + if (!group?.name?.startsWith(prefix)) return false; + return !(group.parent instanceof Group && group.parent.name?.startsWith(prefix)); + }); + for (const root of roots) root.remove(); + } + + function captureActorAnimationBindings(actorIndex) { + const prefix = `actor${actorIndex}_`; + const bindings = new Map(); + for (const group of Group.all || []) { + if (group?.name?.startsWith(prefix)) bindings.set(group.name, group.uuid); + } + // Removing a group deletes its animators, so retain the tracks before removal. + const animations = typeof Animation !== 'undefined' && Array.isArray(Animation.all) ? Animation.all : []; + return animations.map((animation) => ({ + animation, + tracks: Array.from(bindings, ([boneName, uuid]) => ({ boneName, uuid, animator: animation.animators?.[uuid] })) + .filter((track) => track.animator), + })).filter((entry) => entry.tracks.length); + } + + function rebindActorAnimations(actorIndex, previousBindings) { + if (!previousBindings?.length) { + return { rebound: 0, missing: 0 }; + } + + const prefix = `actor${actorIndex}_`; + const replacementGroups = new Map(); + for (const group of Group.all || []) { + if (group?.name?.startsWith(prefix)) replacementGroups.set(group.name, group); + } + + let rebound = 0; + let missing = 0; + for (const { animation, tracks } of previousBindings) { + const animators = animation.animators; + if (!animators || typeof animators !== 'object') continue; + + for (const { boneName, uuid: oldUuid, animator } of tracks) { + const replacement = replacementGroups.get(boneName); + if (!replacement) { + animator._name = boneName; + animators[oldUuid] = animator; + animation.saved = false; + missing++; + continue; + } + + if (typeof animation.removeAnimator === 'function') animation.removeAnimator(oldUuid); + else delete animators[oldUuid]; + animator.uuid = replacement.uuid; + animator._name = replacement.name; + animators[replacement.uuid] = animator; + animation.saved = false; + rebound++; + } + } + + return { rebound, missing }; + } + + function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false) { const bones = Array.isArray(geo?.bones) ? geo.bones : []; if (!bones.length) { Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500); @@ -562,8 +626,11 @@ } const prefix = `actor${actorIndex}_`; + const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null; - Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], animations: [] }); + Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], + animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] }); + if (replaceExisting) removeActorGeometry(actorIndex); const boneByName = new Map(); for (const b of bones) if (b?.name) boneByName.set(b.name, b); @@ -644,11 +711,27 @@ } } + const animationResult = replaceExisting + ? rebindActorAnimations(actorIndex, previousBindings) + : { rebound: 0, missing: 0 }; + Canvas.updateAll(); applyAllActorTexturePreviews(false); - Undo.finishEdit(`Import Actor ${actorIndex}`); + if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select(); + Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`); - Blockbench.showQuickMessage(`Imported actor with prefix "${prefix}"`, 2500); + const animationNotice = replaceExisting && animationResult.rebound + ? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}` + : ''; + Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000); + + if (replaceExisting && animationResult.missing) { + Blockbench.showMessageBox({ + title: 'Replacement Model Missing Animated Bones', + message: `${animationResult.missing} existing animation channel${animationResult.missing === 1 ? '' : 's'} could not be rebound because the replacement model does not contain the matching actor bone. Those channels were left unchanged.`, + buttons: ['OK'], + }); + } } function importActorGeckoJson() { @@ -687,6 +770,74 @@ ); } + function replaceActorGeckoJson() { + if (!Project) { + Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); + return; + } + + const actorOptions = buildActorSelectOptions(); + if (!Object.keys(actorOptions).length) { + Blockbench.showQuickMessage('No actorN_ model found in this project.', 2500); + return; + } + + new Dialog({ + id: 'replace_actor_gecko_json', + title: 'Replace Actor Model', + width: 520, + form: { + actor: { + label: 'Actor to replace', + type: 'select', + options: actorOptions, + value: Object.keys(actorOptions)[0], + }, + _info: { + type: 'info', + text: 'The selected actor model will be replaced. Its actor prefix, animations, and actor preferences are preserved.', + }, + }, + buttons: ['Cancel', 'Choose Model'], + cancelIndex: 0, + confirmIndex: 1, + onConfirm: (result) => { + const actorIndex = Number(result?.actor); + if (!Number.isFinite(actorIndex) || actorIndex <= 0) { + Blockbench.showQuickMessage('Choose a valid actor.', 2500); + return false; + } + + Filesystem.importFile( + { + type: 'GeckoLib / Bedrock Geometry JSON', + extensions: ['json', 'geo.json'], + multiple: false, + readtype: 'text', + resource_id: 'model', + title: 'Select replacement actor model', + }, + (files) => { + if (!files?.length) return; + try { + const json = JSON.parse(files[0]?.content || ''); + const geo = extractGeckoGeometryRoot(json); + if (!geo) { + Blockbench.showQuickMessage('Unsupported JSON structure (expected minecraft:geometry[0]).', 3000); + return; + } + importGeometryIntoCurrentProject(geo, actorIndex, true); + } catch (e) { + console.error(e); + Blockbench.showQuickMessage(`Replacement failed: ${e?.message || e}`, 4000); + } + } + ); + return true; + }, + }).show(); + } + // ---------------------------- // Actor Preferences // ---------------------------- @@ -3538,6 +3689,13 @@ You can import or export complete AFW animation IDs as one file containing all s click: importActorGeckoJson, }); + 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', + click: replaceActorGeckoJson, + }); + importConjoinedAnimationAction = new Action('import_conjoined_actor_animation', { name: 'Import Animation', description: 'Import one conjoined AnimationDirector JSON and rebuild every stage and actor animation.', @@ -3620,6 +3778,7 @@ You can import or export complete AFW animation IDs as one file containing all s importAction, importConjoinedAnimationAction, actorPreferencesAction, + replaceActorAction, exportConjoinedAction, createAnimationDefinitionAction, { @@ -3655,6 +3814,7 @@ You can import or export complete AFW animation IDs as one file containing all s actorPreferencesAction.delete(); boneTextureOverridesAction.delete(); exportGeckoModelAction.delete(); + replaceActorAction.delete(); exportSelectedAction.delete(); exportAllAction.delete(); exportConjoinedAction.delete(); diff --git a/test/model-replacement.test.js b/test/model-replacement.test.js new file mode 100644 index 0000000..9f15737 --- /dev/null +++ b/test/model-replacement.test.js @@ -0,0 +1,59 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +test('retains all stage tracks when group removal deletes their animation bindings', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( + ' Plugin.register(PLUGIN_ID, {', + ' globalThis.replacementTest = { captureActorAnimationBindings, removeActorGeometry, rebindActorAnimations };\n Plugin.register(PLUGIN_ID, {' + ); + const animations = []; + class Group { + static all = []; + constructor(name, uuid, parent = 'root') { + Object.assign(this, { name, uuid, parent }); + Group.all.push(this); + } + remove() { + for (const child of Group.all.filter((group) => group.parent === this)) child.remove(); + for (const animation of animations) animation.removeAnimator(this.uuid); + Group.all = Group.all.filter((group) => group !== this); + } + } + const root = new Group('actor1_body', 'old-body'); + new Group('actor1_head', 'old-head', root); + new Group('actor1_removed', 'old-removed', root); + new Group('actor2_body', 'other-body'); + for (let stage = 1; stage <= 2; stage++) { + const animators = {}; + for (const group of Group.all) { + const track = { uuid: group.uuid, _name: group.name, keyframes: [{ time: stage, rotation: [1, 2, 3] }] }; + Object.defineProperty(track, 'name', { get() { return this._name; } }); + animators[group.uuid] = track; + } + animations.push({ animators, saved: true, removeAnimator(uuid) { delete this.animators[uuid]; } }); + } + const originalTracks = animations.map((animation) => ({ ...animation.animators })); + const context = { Group, Animation: { all: animations }, Plugin: { register() {} }, console }; + vm.runInNewContext(source, context); + const snapshot = context.replacementTest.captureActorAnimationBindings(1); + context.replacementTest.removeActorGeometry(1); + assert.equal(animations[0].animators['old-body'], undefined); + new Group('actor1_body', 'new-body'); + new Group('actor1_head', 'new-head'); + const result = context.replacementTest.rebindActorAnimations(1, snapshot); + assert.equal(result.rebound, 4); + assert.equal(result.missing, 2); + animations.forEach((animation, index) => { + assert.equal(animation.animators['new-body'], originalTracks[index]['old-body']); + assert.equal(animation.animators['new-head'], originalTracks[index]['old-head']); + assert.equal(animation.animators['other-body'], originalTracks[index]['other-body']); + assert.equal(animation.animators['old-removed'], originalTracks[index]['old-removed']); + assert.equal(animation.animators['new-body'].uuid, 'new-body'); + assert.equal(animation.animators['old-body'], undefined); + assert.equal(animation.saved, false); + assert.deepEqual(animation.animators['new-body'].keyframes, [{ time: index + 1, rotation: [1, 2, 3] }]); + }); +});