Compare commits

..
32 Commits
Author SHA1 Message Date
L1Z0 41c9cda88f tweak 2026-09-29 15:51:29 +02:00
L1Z0 312dbf7e92 tweak 2026-09-29 15:36:03 +02:00
L1Z0 7f1c7d70f8 tweaks 2026-09-29 15:24:45 +02:00
L1Z0 5cb0ba631e tweaks 2026-09-29 14:54:06 +02:00
L1Z0 f7c0c38ec2 tweaks 2026-09-29 14:23:43 +02:00
L1Z0 b61142f9bd export jem model 2026-09-29 14:19:27 +02:00
L1Z0 297dcd82da export jem model 2026-09-29 14:19:24 +02:00
L1Z0 4735c33e6e copy first keyframe button 2026-09-29 13:45:53 +02:00
L1Z0 9920f1da4c copy first keyframe button 2026-09-29 13:29:42 +02:00
L1Z0 94b3145254 copy last stage button 2026-09-29 13:19:39 +02:00
L1Z0 3489315611 tweaks and improvements 2026-09-29 02:02:50 +02:00
L1Z0 53a5c47549 tweaks and improvements 2026-09-29 01:58:15 +02:00
L1Z0 61df6bb0d3 tweaks and improvements 2026-09-29 01:50:32 +02:00
L1Z0 474f244adb sound library 2026-09-29 01:07:20 +02:00
L1Z0 55d5593804 sound library 2026-09-29 01:07:15 +02:00
L1Z0 6d4094e7c4 improvements 2026-09-29 00:43:09 +02:00
L1Z0 0bceb63671 improvements 2026-09-29 00:25:32 +02:00
L1Z0 8cfdf0ecec improvements 2026-09-29 00:10:46 +02:00
L1Z0 d04f26465f UV fixes, supporting differently sized textures 2026-09-29 00:01:48 +02:00
L1Z0 37e30e805f improvements 2026-09-28 23:32:07 +02:00
L1Z0 2bae0c3946 improvements 2026-09-28 23:22:53 +02:00
L1Z0 3206b1b3f1 improvements 2026-09-28 23:01:07 +02:00
L1Z0 27a42a076b props 2026-09-28 22:56:40 +02:00
L1Z0 733b62194a props 2026-09-28 22:39:00 +02:00
L1Z0 8d1346986b improved and fixed stuff 2026-09-28 21:55:40 +02:00
L1Z0 2486c3cb63 added a way to auto import full animations 2026-09-28 21:30:49 +02:00
L1Z0 3505efed8c block requirements 2026-09-28 19:58:29 +02:00
L1Z0 26f7aaecc1 block requirements 2026-09-28 19:52:09 +02:00
L1Z0 8764af4c1a block requirements 2026-09-28 18:56:59 +02:00
L1Z0 f1f597dcca added way of replacing actor models 2026-09-28 18:40:30 +02:00
L1Z0 a5e72e1d74 fixed locators not being imported 2026-09-28 18:18:06 +02:00
L1Z0 9d41f962eb animdef generator overhaul 2026-09-28 18:16:41 +02:00
13 changed files with 6727 additions and 756 deletions
+4028 -756
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# AnimationDirector
Blockbench tools for creating multi-actor animations and animation definitions for Needs of Nature.
## Installation And Requirements
- Install `AnimationDirector.js` using Blockbench's **Plugins > Load Plugin from File**. No separate logo or asset files are required.
- Supported baseline: Blockbench **5.2.1 or newer**.
- Enable the **GeckoLib** format plugin to create/import multi-actor animation projects.
- **Create JEM Model** requires **CEM Template Loader**. Its preset loader may require an internet connection.
- Desktop Blockbench is required for automatic import, pack folders, item props, JEM transfer, and the NoN sound library. These actions are hidden in browser Blockbench; manual JSON import/export remains available.
- **Asset Browser** is optional. AnimationDirector can use its downloaded Minecraft asset cache, installed Minecraft client archives, or manually selected assets. It does not download vanilla textures itself.
## Workflow
1. Use **Tools > AnimationDirector Anims > Import Whole Animation** to select an animdef or `.animation.json` from an extracted pack. A new GeckoLib project is created after reviewing actors and models.
2. Alternatively, import actors and animations manually. Actor bones use `actorN_` prefixes to prevent name collisions.
3. Use **Actor Preferences** to set actor names, preview textures, and matching information.
4. Configure **Block Requirements** with live required-block/clearance previews, and **Item Props** with locally available assets or a PNG texture.
5. Use **Animation Definition** to save generator fields in the project or export an animdef. Imported definitions replace the saved fields for that animation.
6. Use **Export Animations** to export all physical stages and actor clips for an animation ID. Keep the Blockbench project as the editable source.
## Preview Tools
- **NoN Sound Library** auditions cues and assigns local sound previews to imported/created sound keyframes. These preview files are not exported as animation cues.
- **Autoplay Stages** advances non-looping clips to the next physical stage of the same animation ID. It takes priority over Blockbench's global loop toggle without changing that preference. Looping clips and custom timeline ranges remain unchanged.
- The timeline pose buttons copy the preceding stage's final pose to the current start, or copy keyed channels from the current start to its end.
- Mixed texture-size UV normalization is applied only to GeckoLib projects. Other formats retain their native UV sizing.
- **Tools > AnimationDirector Model > Create JEM Model** transfers selected additions into a CEM preset. It does not transfer animation keyframes, locators, or modifications to existing vanilla cubes. Review the resulting model and save its textures alongside the exported JEM as appropriate.
## Checks
```sh
node --check AnimationDirector.js
node -e "for(const f of require('node:fs').readdirSync('test').filter(f=>f.endsWith('.test.js')))require('./test/'+f)"
```
The tests use Blockbench API doubles. Before release, also check real Blockbench imports, Undo/Redo, JEM texture export, mixed atlas sizes, sound playback, and plugin unload/reload.
View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+318
View File
@@ -0,0 +1,318 @@
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');
function createHarness() {
const file = path.join(__dirname, '..', 'AnimationDirector.js');
const source = fs.readFileSync(file, 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.adTest = { showAnimationDefinitionReviewDialog, createInjectorGenderDefaultHandler };\n Plugin.register(PLUGIN_ID, {'
);
const dialogs = [];
const exports = [];
const errors = [];
let formResult;
const project = { multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
const context = {
Project: project,
Plugin: { register() {} },
Dialog: class {
constructor(options) { this.options = options; dialogs.push(options); }
show() {}
getFormResult() { return formResult ?? defaults(this.options.form); }
},
Blockbench: {
export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
showMessageBox(options) { errors.push(options.message); },
showQuickMessage() {},
},
console,
};
vm.runInNewContext(source, context, { filename: file });
const group = { id: 'test_animation' };
const analysis = {
actorIndices: [1, 2],
actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
stages: [
{ stageNumber: 1, name: 'test_animation.p1', loop: true, cycleSeconds: 1 },
{ stageNumber: 2, name: 'test_animation.p2', loop: false, cycleSeconds: 2 },
],
warnings: [],
};
function open() {
context.adTest.showAnimationDefinitionReviewDialog(group, analysis);
return dialogs.at(-1);
}
return { open, exports, errors, project, analysis, createInjectorGenderDefaultHandler: context.adTest.createInjectorGenderDefaultHandler,
setFormResult(result) { formResult = result; } };
}
function defaults(form) {
return Object.fromEntries(Object.entries(form)
.filter(([, field]) => field && typeof field === 'object' && 'value' in field)
.map(([key, field]) => [key, field.value]));
}
test('selecting an injector adds an editable male tag without enforcing it on export', () => {
for (const role of ['V', 'A', 'M']) {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form), updates = [];
const instance = { form: { setValues(values, notify) { updates.push(values); assert.equal(notify, false); } } };
result.stub_actor_1_actor_tags = 'custom.tag';
result.stub_actor_1_injector = role;
dialog.onFormChange.call(instance, result);
assert.equal(result.stub_actor_1_actor_tags, 'custom.tag, gender.male');
assert.equal(updates.length, 1);
result.stub_actor_1_actor_tags = 'custom.tag';
dialog.onFormChange.call(instance, result);
result.display_name = 'Unrelated edit';
dialog.onFormChange.call(instance, result);
assert.equal(updates.length, 1, 'Manual removal survives unrelated changes');
result.stub_actor_2_entity_types = 'minecraft:wolf';
dialog.onConfirm(result);
assert.equal(h.errors.length, 0);
assert.equal(h.exports[0].actors[0].injector, role);
assert.deepEqual(h.exports[0].actors[0].actor_tags, ['custom.tag']);
}
});
test('injector defaults preserve loaded roles, existing tags, and actor preferences edits', () => {
const h = createHarness();
const form = {
preferences_animdef_1_injector: { value: 'V' },
preferences_animdef_2_injector: { value: '' },
};
const update = h.createInjectorGenderDefaultHandler(form), writes = [];
const dialog = { form: { setValues(values) { writes.push(values); } } };
const result = { preferences_animdef_1_injector: 'V', preferences_animdef_1_actor_tags: 'custom.tag',
preferences_animdef_2_injector: '', preferences_animdef_2_actor_tags: 'gender.male, other.tag' };
update(dialog, result);
assert.equal(writes.length, 0, 'Opening an imported/saved role does not add a tag');
result.preferences_animdef_2_injector = 'A'; update(dialog, result);
assert.equal(writes.length, 0, 'Existing male tags are not duplicated');
result.preferences_animdef_2_injector = ''; update(dialog, result);
assert.equal(result.preferences_animdef_2_actor_tags, 'gender.male, other.tag', 'Deselecting a role does not remove explicit tags');
result.preferences_animdef_2_actor_tags = 'other.tag';
result.preferences_animdef_2_injector = 'M'; update(dialog, result);
assert.equal(result.preferences_animdef_2_actor_tags, 'other.tag, gender.male');
assert.equal(result.preferences_animdef_1_actor_tags, 'custom.tag', 'Only the changed actor is affected');
});
test('Save retains definition fields in the project without exporting and restores them on reopen', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
assert.deepEqual(Array.from(dialog.buttons), ['Cancel', 'Save', 'Create Animdef']);
assert.equal(dialog.confirmIndex, 2);
result.display_name = 'Saved definition';
result.content_tags = 'saved, tags';
result.weight = '3';
result.stub_stage_1_peaked = true;
result.stub_stage_2_peaked = false;
h.setFormResult(result);
assert.equal(dialog.onButton(1), false);
assert.equal(h.exports.length, 0);
assert.deepEqual(h.errors, []);
assert.equal(h.project.saved, false);
const reopened = h.open();
assert.equal(reopened.form.display_name.value, result.display_name);
assert.equal(reopened.form.content_tags.value, result.content_tags);
assert.equal(reopened.form.weight.value, 3);
assert.equal(reopened.form.stub_stage_1_peaked.value, true);
assert.equal(reopened.form.stub_stage_2_peaked.value, false);
assert.equal(reopened.onConfirm(defaults(reopened.form)), true);
assert.equal(h.exports[0].display_name, result.display_name);
});
test('invalid Save leaves previously saved fields intact', () => {
const h = createHarness(), dialog = h.open(), result = defaults(dialog.form);
result.display_name = 'Original';
h.setFormResult(result); dialog.onButton(1);
const original = h.project.animationdirector_animdef_definitions;
h.setFormResult({ ...result, display_name: 'Invalid', weight: '0' });
dialog.onButton(1);
assert.equal(h.project.animationdirector_animdef_definitions, original);
assert.equal(h.exports.length, 0);
assert.equal(h.errors.length, 1);
});
test('metadata-only playback stages remain editable and survive definition export', () => {
const h = createHarness();
h.analysis.stages = h.analysis.stages.slice(0, 1);
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
stages: [{ stage: 1, loop: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }],
} });
const dialog = h.open();
assert.equal(dialog.form.stub_stage_2_use_stage.value, '1');
dialog.onConfirm(defaults(dialog.form));
assert.deepEqual(h.errors, []);
assert.equal(h.exports[0].stages.length, 2);
assert.equal(h.exports[0].stages[1].use_stage, 1);
assert.equal(h.exports[0].stages[1].speed, 0.8);
assert.equal(h.analysis.stages.length, 1);
});
test('prop tool metadata survives generator export including reused-stage overrides', () => {
const h = createHarness();
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
actors: [{label:'player',entity_types:['minecraft:player'],prop_right:'minecraft:carrot'},
{label:'wolf',entity_types:['minecraft:wolf'],prop_floor:'minecraft:stone'}],
stages: [{stage:1,props:{player:{prop_left:'minecraft:stick'}}},
{stage:2,use_stage:1,props:{player:{prop_right:null},wolf:null}}],
} });
const dialog=h.open(); dialog.onConfirm(defaults(dialog.form));
assert.deepEqual(h.errors,[]);
const def=h.exports[0];
assert.equal(def.actors[0].prop_right,'minecraft:carrot');
assert.equal(def.actors[1].prop_floor,'minecraft:stone');
assert.equal(def.stages[0].props.player.prop_left,'minecraft:stick');
assert.equal(def.stages[1].props.player.prop_right,null);
assert.equal(def.stages[1].props.wolf,null);
});
test('generates extended animdef fields and restores them on reopen', () => {
const harness = createHarness();
const dialog = harness.open();
const result = defaults(dialog.form);
result.stub_actor_1_injector = 'V';
result.stub_actor_1_receiver = true;
result.stub_actor_1_injects_into = ['wolf'];
result.stub_actor_1_held_items = 'minecraft:carrot, #minecraft:swords';
result.stub_actor_1_held_hand = 'either';
result.stub_actor_1_held_prop_slot = 'right';
result.stub_actor_2_receiver = true;
result.system_tags = 'manualpeak';
result.content_tags = 'test, tag';
result.required_union_tags = 'example.tag';
result.join_after = 'creator:previous';
result.match_content_on_join = true;
result.weight = '2.5';
result.attack_eligible = false;
result.manual_peak_priority = '10';
result.liquid_gain_multiplier = '0.5';
result.base_speed = '1.25';
result.water = 'surface';
result.position_anchor_actor = 'wolf';
result.camera_torso_tracking = 'continuous';
result.camera_block_collision = 'ignore';
result.stub_stage_2_use_stage = '1';
assert.equal(dialog.onConfirm(result), true);
assert.equal(harness.errors.length, 0);
assert.equal(harness.exports.length, 1);
const definition = harness.exports[0];
assert.equal(definition.weight, 2.5);
assert.equal(definition.attack_eligible, false);
assert.deepEqual(Array.from(definition.system_tags), ['manualpeak']);
assert.deepEqual(Array.from(definition.content_tags), ['test', 'tag']);
assert.deepEqual(Array.from(definition.join_after), ['creator:previous']);
assert.equal(definition.manual_peak.priority, 10);
assert.equal(definition.actors[0].injects_into, 'wolf');
assert.equal(definition.actors[0].receiver, true);
assert.equal(definition.actors[0].held_item.prop_slot, 'right');
assert.equal('prop_floor' in definition.actors[1], false);
assert.equal(definition.stages[1].use_stage, 1);
assert.equal(definition.camera.torso_tracking, 'continuous');
assert.equal(definition.water, 'surface');
assert.equal(definition.position_anchor_actor, 'wolf');
assert.equal('stage_seconds' in definition, false);
assert.equal('escapable' in definition, false);
const reopened = harness.open();
assert.equal(reopened.form.weight.value, 2.5);
assert.equal(reopened.form.matching_advanced.value, true);
assert.equal(reopened.form.system_tags.value, 'manualpeak');
assert.equal(reopened.form.stub_actor_1_injector.value, 'V');
assert.deepEqual(Array.from(reopened.form.stub_actor_1_injects_into.value), ['wolf']);
assert.equal(reopened.form.stub_stage_2_use_stage.value, '1');
});
test('rejects nonexistent injection targets and playback stages', () => {
const harness = createHarness();
const dialog = harness.open();
const result = defaults(dialog.form);
result.stub_actor_1_injector = 'V';
result.stub_actor_1_injects_into = ['missing'];
assert.equal(dialog.onConfirm(result), false);
assert.match(harness.errors.at(-1), /invalid injection target/);
result.stub_actor_1_injects_into = ['wolf'];
result.stub_stage_2_use_stage = '99';
assert.equal(dialog.onConfirm(result), false);
assert.match(harness.errors.at(-1), /missing playback stage/);
assert.equal(harness.exports.length, 0);
});
test('groups advanced matching and offers only other actors as injection targets', () => {
const form = createHarness().open().form;
const keys = Object.keys(form);
assert.equal(form.matching_advanced.value, false);
for (const key of ['required_union_tags', 'join_after', 'match_content_on_join', 'weight']) {
assert.equal(form[key].condition({ matching_advanced: false }), false);
assert.equal(form[key].condition({ matching_advanced: true }), true);
assert.ok(keys.indexOf(key) > keys.indexOf('matching_advanced'));
}
assert.equal(form.stub_actor_1_receiver.label, 'Receiver');
assert.equal(form.stub_actor_1_injects_into.type, 'multi_select');
assert.deepEqual(Object.keys(form.stub_actor_1_injects_into.options), ['wolf']);
assert.deepEqual(Object.keys(form.stub_actor_2_injects_into.options), ['player']);
assert.equal(keys.indexOf('stub_actor_1_injects_into'), keys.indexOf('stub_actor_1_injector') + 1);
assert.equal(keys.some((key) => /_prop_(left|right|floor)$/.test(key)), false);
assert.equal('default_stage_seconds' in form, false);
assert.equal('default_escapable' in form, false);
assert.equal('stub_behavior' in form, false);
assert.equal(form.system_tags.type, 'select');
assert.deepEqual(Object.keys(form.system_tags.options), [
'', 'manualpeak', 'defeated', 'stuck', 'tease', 'birth', 'fillbottle', 'other',
]);
assert.equal(form.stub_stage_2_use_stage.type, 'select');
assert.deepEqual(Object.keys(form.stub_stage_2_use_stage.options), ['1', '2', '']);
});
test('keeps minimal definitions minimal and supports specialized behavior', () => {
const harness = createHarness();
const dialog = harness.open();
const minimal = defaults(dialog.form);
assert.equal(dialog.onConfirm(minimal), true);
const first = harness.exports.at(-1);
for (const key of ['weight', 'camera', 'water', 'manual_peak', 'block_requirements']) {
assert.equal(key in first, false, `${key} should be omitted by default`);
}
const teaseDialog = harness.open();
const tease = defaults(teaseDialog.form);
tease.system_tags = 'tease';
tease.stub_tease_multiplier = 3;
assert.equal(teaseDialog.onConfirm(tease), true);
const second = harness.exports.at(-1);
assert.deepEqual(Array.from(second.system_tags), ['tease']);
assert.equal(second.tease.near_animation_energy_multiplier, 3);
const stuckDialog = harness.open();
const stuck = defaults(stuckDialog.form);
stuck.system_tags = 'stuck';
stuck.content_tags = 'stuckfence';
assert.equal(stuckDialog.onConfirm(stuck), true);
const third = harness.exports.at(-1);
assert.deepEqual(Array.from(third.system_tags), ['stuck']);
assert.deepEqual(Array.from(third.content_tags), ['stuckfence']);
assert.equal(third.stuck.damage_behavior, 'stop_on_damage');
});
test('exports one custom behavior through Other', () => {
const harness = createHarness();
const dialog = harness.open();
const result = defaults(dialog.form);
result.system_tags = 'other';
result.other_system_tags = 'custom_tag';
assert.equal(dialog.onConfirm(result), true);
const definition = harness.exports.at(-1);
assert.deepEqual(Array.from(definition.system_tags), ['custom_tag']);
assert.equal('tease' in definition, false);
assert.equal('stuck' in definition, false);
const reopened = harness.open();
assert.equal(reopened.form.system_tags.value, 'other');
assert.equal(reopened.form.other_system_tags.value, 'custom_tag');
assert.equal(reopened.form.other_system_tags.condition({ system_tags: 'other' }), true);
assert.equal(reopened.form.other_system_tags.condition({ system_tags: '' }), false);
});
+570
View File
@@ -0,0 +1,570 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const plugin = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
const io = { fs, path };
function harness(capture = false) {
const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', `
globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry,
resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles,
validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport, resolveDefinitionImportStages,
createImportAssetLookup, importGeometryIntoCurrentProject };
Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {',
` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`);
const storage = new Map(), dialogs = [], errors = [], undo = [], messages = [];
let sequence = 0;
class Group {
static all = [];
constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); }
init() { Group.all.push(this); }
addTo(parent) { this.parent = parent; if (typeof parent === 'object') parent.children.push(this); }
}
class Texture {
static all = [];
constructor(data) { Object.assign(this, data); }
fromPath(file) { this.path = file; this.name = path.basename(file); return this; }
fromDataURL(url) { this.source = url; return this; }
add(undo) { assert.equal(undo, false); Texture.all.push(this); }
}
const context = {
Buffer, Blob, URL: { createObjectURL() { return 'blob:import-preview'; }, revokeObjectURL() {} }, Timeline: { animation: null },
Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] },
Plugin: { register() {} }, requireNativeModule: require,
localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) },
Formats: { geckolib_model: { id: 'geckolib_model' } },
newProject(format) {
assert.equal(format.id, 'geckolib_model');
context.Format = format;
context.Project = { uuid: 'new-project' };
return true;
},
Undo: {
initEdit(aspects) { undo.push({ type: 'begin', aspects }); },
finishEdit(action, aspects) { undo.push({ type: 'finish', action, aspects }); },
cancelEdit() { undo.push({ type: 'cancel' }); Group.all = []; Texture.all = []; context.Animation.all = []; },
},
Animator: {
loadFile(file, names) {
context.loadedAnimation = JSON.parse(file.content);
context.Animation.all = names.map((name) => ({ name, select() {}, animators: { effects: { sound:
Object.entries(context.loadedAnimation.animations[name]?.sound_effects || {}).map(([time, cues]) => ({
time: Number(time), data_points: Array.isArray(cues) ? cues : [cues],
})) } } }));
},
},
Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} },
setTimeout() { return 1; }, clearTimeout() {}, console,
Blockbench: { showMessageBox(options) { errors.push(options.message); }, showQuickMessage(message) { messages.push(message); } },
Filesystem: { importFile(options, callback) { callback(context.selectedFile ? [context.selectedFile] : []); } },
Dialog: class {
constructor(options) { this.options = options; }
show() {
dialogs.push(this.options);
const result = Object.fromEntries(Object.entries(this.options.form).filter(([, value]) => 'value' in value)
.map(([key, value]) => [key, value.value]));
const choice = context.answer?.(this.options, result) ?? result;
if (choice === false) this.options.onCancel();
else this.options.onConfirm(choice);
}
},
};
vm.runInNewContext(source, context);
return { ...context.api, context, storage, dialogs, errors, undo, messages };
}
function fixtures(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'animationdirector-import-'));
t.after(() => {
assert.equal(path.dirname(root), fs.realpathSync(os.tmpdir()));
fs.rmSync(root, { recursive: true, force: true });
});
const write = (file, data) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, typeof data === 'string' ? data : JSON.stringify(data));
return file;
};
const makePack = (name, metadata = {}) => {
const folder = path.join(root, name);
write(path.join(folder, 'pack.mcmeta'), { animationframework: { id: `test:${name}`, ...metadata } });
return harness().readImportPack(folder, io);
};
const model = (pack, id, description = {}) => {
const [namespace, resource] = id.split(':');
return write(path.join(pack.root, 'assets', namespace, 'geckolib', 'models', `${resource}.geo.json`),
{ ...description, 'minecraft:geometry': [{ description: { texture_width: 64, texture_height: 64 }, bones: [{ name: 'body' }] }] });
};
return { root, write, makePack, model };
}
function scopedFilesystem() {
return Object.fromEntries(['existsSync', 'readFileSync', 'readdirSync', 'statSync']
.map((method) => [method, fs[method].bind(fs)]));
}
test('Blockbench scoped filesystem without realpathSync supports folder registration and resource lookup', (t) => {
const f = fixtures(t), h = harness(), pack = f.makePack('source');
h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path;
const restricted = h.desktopFiles();
assert.equal(restricted.fs.realpathSync, undefined);
const model = f.model(pack, 'test:source/entity/wolf.m');
const source = h.readImportPack(pack.root, restricted);
assert.equal(source.root, path.resolve(pack.root));
assert.equal(h.findImportPack(model, restricted).root, source.root);
assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', restricted).file, model);
assert.equal(h.importPackRegistry(source, [source.root], restricted).warnings.length, 0);
assert.throws(() => h.insidePack(source.root, '../outside.json', restricted), /escapes/);
});
test('scoped filesystem fallback rejects linked paths instead of bypassing containment checks', (t) => {
const f = fixtures(t), h = harness(), pack = f.makePack('source');
const link = path.join(pack.root, 'linked');
const target = path.join(f.root, 'outside');
fs.mkdirSync(target);
fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
assert.throws(() => h.importFilePath(link, { fs: scopedFilesystem(), path }), /Linked paths/);
});
test('denied filesystem permission reports an actionable message', () => {
const h = harness();
h.context.requireNativeModule = (name) => name === 'fs' ? undefined : path;
assert.throws(() => h.desktopFiles(), /Allow filesystem access/);
});
test('model lookup respects local/import/shared priority per gender candidate, not per provider', (t) => {
const f = fixtures(t), h = harness();
const source = f.makePack('source', { model_imports: [{ pack: 'test:provider', entities: ['minecraft:wolf'] }] });
const provider = f.makePack('provider');
f.model(source, 'test:source/entity/wolf');
const imported = f.model(provider, 'test:provider/entity/wolf.m');
const shared = f.model(provider, 'animationframework:entity/wolf.m');
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, imported);
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Imported test:provider');
f.model(source, 'test:source/entity/wolf.m');
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Local');
source.metadata.model_imports = [];
fs.unlinkSync(path.join(source.root, 'assets/test/geckolib/models/source/entity/wolf.m.geo.json'));
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, shared);
});
test('imports are entity-specific and non-transitive; shared overrides follow folder order', (t) => {
const f = fixtures(t), h = harness();
const source = f.makePack('source', { model_imports: [{ pack: 'test:b', entities: ['minecraft:wolf'] }] });
const b = f.makePack('b', { model_imports: [{ pack: 'test:c', entities: ['minecraft:wolf'] }] });
const c = f.makePack('c');
f.model(c, 'test:c/entity/wolf.m');
f.model(b, 'test:b/entity/horse.m');
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io), null);
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:horse', 'm', '', io), null);
const top = f.model(b, 'animationframework:entity/wolf.m');
f.model(c, 'animationframework:entity/wolf.m');
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io).file, top);
});
test('sparse default revisions resolve oldest applicable model before current shared models', (t) => {
const f = fixtures(t), h = harness();
const source = f.makePack('source');
const defaults = f.makePack('defaults', { id: 'needsofnature:default_pack', default_model_revision: 4 });
const old = f.model(defaults, 'needsofnature:default_pack_compat/revision_2/entity/horse.m');
const later = f.model(defaults, 'needsofnature:default_pack_compat/revision_3/entity/horse.m');
const current = f.model(defaults, 'animationframework:entity/horse.m');
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, old);
source.metadata.default_model_revision = 3;
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, later);
source.metadata.default_model_revision = 4;
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, current);
});
test('variant, slim and male+female candidates use NoN fallback order, including legacy shared paths', (t) => {
const f = fixtures(t), h = harness(), source = f.makePack('source');
assert.deepEqual(Array.from(h.importModelCandidates('minecraft:wolf', 'mf', 'snow')), [
'wolf_snow.mf', 'wolf.mf', 'wolf_snow', 'wolf', 'wolf_snow.m', 'wolf.m', 'wolf_snow.f', 'wolf.f',
]);
assert.equal(h.importModelCandidates('minecraft:player', 'f', '', true)[0], 'player_slim.f');
const legacy = f.model(source, 'animationframework:wolf.f');
assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', io).file, legacy);
});
test('companion lookup accepts either entry point and detects namespace alternatives without broad scans', (t) => {
const f = fixtures(t), h = harness(), pack = f.makePack('source');
const def = f.write(path.join(pack.root, 'data/test/afw_animdefs/example.json'), {});
const animation = f.write(path.join(pack.root, 'assets/other/animations/afw/example.animation.json'), {});
assert.equal(h.findImportPack(def, io).root, pack.root);
assert.deepEqual(Array.from(h.companionImportFiles(pack, def, io).animations), [animation]);
assert.deepEqual(Array.from(h.companionImportFiles(pack, animation, io).definitions), [def]);
assert.throws(() => h.companionImportFiles(pack, path.join(pack.root, 'random.json'), io), /Choose data/);
const nested = f.write(path.join(pack.root, 'assets/test/geckolib/animations/afw/nested/nested.animation.json'), {});
const nestedDef = f.write(path.join(pack.root, 'data/test/afw_animdefs/nested.json'), {});
assert.equal(h.companionImportFiles(pack, nested, io).id, 'nested');
assert.deepEqual(Array.from(h.companionImportFiles(pack, nested, io).definitions), [nestedDef]);
});
test('resource paths cannot escape packs, and invalid/cyclic models are rejected before importing', (t) => {
const f = fixtures(t), h = harness(), pack = f.makePack('source');
assert.throws(() => h.insidePack(pack.root, '../escape.json', io), /escapes/);
for (const id of ['test:../file', 'test:a/../../file', 'test:/absolute', 'test:a//file']) assert.equal(h.importResourceId(id), null);
assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }] }] }), /Cyclic/);
assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a' }, { name: 'a' }] }] }), /unique/);
});
test('registry preserves explicit priority, inserts only new source first, and reports unavailable folders', (t) => {
const f = fixtures(t), h = harness(), source = f.makePack('source'), top = f.makePack('top');
assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root, source.root], io).packs, (pack) => pack.root), [top.root, source.root]);
assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root], io).packs, (pack) => pack.root), [source.root, top.root]);
assert.equal(h.importPackRegistry(source, [path.join(f.root, 'missing')], io).warnings.length, 1);
});
test('actor keys match NoN including required tags, repeated roles and entity variants', () => {
const h = harness();
assert.deepEqual(Array.from(h.definitionActorKeys([
{ entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
{ entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
{ label: 'Custom Role' },
{ entity_types: ['minecraft:fox'], actor_tags_any: ['gender.male', 'gender.female'], entity_variant: 'snow', age: 'baby' },
])), ['wolf_gender_male', 'wolf_gender_male_2', 'custom_role', 'fox_any_gender_female_gender_male_snow_baby']);
});
test('short clip names are inferred only for unambiguous actors; repeated entities require explicit choices', () => {
const h = harness();
assert.deepEqual(Array.from(h.defaultImportClipRoles([
{ entity_types: ['minecraft:player'] }, { entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
], ['player', 'wolf'])), ['player', 'wolf']);
assert.deepEqual(Array.from(h.defaultImportClipRoles([
{ entity_types: ['minecraft:player'], actor_tags: ['gender.male'] },
{ entity_types: ['minecraft:player'], actor_tags: ['gender.female'] },
], ['player', 'player2'])), ['', '']);
});
test('complete import prepares all stages, actor models, local textures and definition fields without touching the current project', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const def = { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf', 'minecraft:fox'], actor_tags: ['gender.male'],
prop_right: 'minecraft:carrot' }],
animators: ['Creator'], content_tags: ['example'], stages: [{ stage: 1, non_peak: true }, { stage: 2 }] };
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), def);
f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), { animations: {
p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } },
p2_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } } },
} });
f.model(source, 'test:source/entity/wolf.m', { afw_texture: 'test:textures/wolf.png' });
f.write(path.join(source.root, 'assets/test/textures/wolf.png'), 'local image');
f.write(path.join(source.root, 'assets/minecraft/items/carrot.json'), { model: { type: 'minecraft:model', model: 'minecraft:item/carrot' } });
f.write(path.join(source.root, 'assets/minecraft/models/item/carrot.json'), {
parent: 'minecraft:builtin/generated', textures: { layer0: 'minecraft:item/carrot' } });
f.write(path.join(source.root, 'assets/minecraft/textures/item/carrot.png'), 'image');
h.context.selectedFile = { path: defFile };
h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path;
await h.automaticallyImportAnimation();
assert.deepEqual(h.errors, []);
assert.equal(h.context.Project.uuid, 'original');
const plan = h.context.importedPlan;
assert.equal(plan.rows[0].resolved.kind, 'Local');
assert.equal(plan.rows[0].entity, 'minecraft:wolf');
const importDialog = h.dialogs.find((dialog) => dialog.title.endsWith('Actors & Models'));
const actorForm = importDialog.form;
assert.equal(actorForm.entity_0.type, 'select');
assert.equal(actorForm.entity_0.value, 'minecraft:wolf');
assert.equal(actorForm.entity_0.options['minecraft:fox'], 'minecraft:fox');
assert.equal(actorForm.gender_0.value, 'm');
assert.equal(actorForm.models_section.type, 'info');
assert.match(actorForm.source_0.value, /Local:.*wolf\.m\.geo\.json/);
assert.equal(actorForm.model_0.type, 'file');
assert.equal(h.dialogs.length, 1, 'Import needs only one configuration dialog');
let updated;
importDialog.onFormChange.call({ form: { setValues(values, rerender) {
assert.equal(rerender, false);
updated = values;
} } }, { entity_0: 'minecraft:fox', gender_0: 'f', slim_0: false });
assert.match(updated.source_0, /No automatic model found/);
assert.equal(plan.rows[0].textures.size, 1);
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1', 'example.p2']);
assert.ok(plan.imported.fileObj.animations['example.p2'].bones.actor1_body);
assert.equal(plan.definition.actors[0].label, 'wolf');
assert.equal(Object.values(plan.propEntries)[0].item, 'minecraft:carrot');
assert.equal(plan.definition.stages[1].non_peak, undefined);
assert.equal(h.undo.length, 0);
assert.ok(h.storage.get('animationdirector_pack_folders').includes(source.root.replace(/\\/g, '\\\\')));
});
test('complete import finds model texture in cached assets and uses its PNG dimensions', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
});
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
animations: { p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } } },
});
f.model(source, 'test:source/entity/wolf.m');
const cache = path.join(f.root, 'cached-assets');
const image = path.join(cache, 'assets/test/textures/entity/wolf.png');
fs.mkdirSync(path.dirname(image), { recursive: true });
const png = Buffer.alloc(24);
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
fs.writeFileSync(image, png);
h.storage.set('animationdirector_prop_source', cache);
h.context.selectedFile = { path: defFile };
await h.automaticallyImportAnimation();
assert.deepEqual(h.errors, []);
const row = h.context.importedPlan.rows[0];
assert.equal(row.defaultTexture, 'test:textures/entity/wolf.png');
assert.equal(row.textures.get(row.defaultTexture).dimensions.width, 32);
assert.equal(row.textures.get(row.defaultTexture).dimensions.height, 64);
const importing = harness();
importing.commitAutomaticImport(h.context.importedPlan);
assert.equal(importing.context.Texture.all[0].uv_width, 32);
assert.equal(importing.context.Texture.all[0].uv_height, 64);
assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 64,
'The actor retains its geometry basis; each textured cube tracks its own normalization');
assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture);
});
test('alternative vanilla texture paths do not add unused textures unless a bone references them', (t) => {
const f = fixtures(t), h = harness(), source = f.makePack('source');
const model = f.model(source, 'test:source/entity/silverfish.m');
const cache = path.join(f.root, 'cached-assets');
const png = Buffer.alloc(24);
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
png.writeUInt32BE(64, 16); png.writeUInt32BE(32, 20);
for (const relative of ['silverfish.png', 'silverfish/silverfish.png']) {
const file = path.join(cache, 'assets/minecraft/textures/entity', relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, png);
}
h.storage.set('animationdirector_prop_source', cache);
const json = JSON.parse(fs.readFileSync(model, 'utf8'));
const row = { json, geo: json['minecraft:geometry'][0], entity: 'minecraft:silverfish', slim: false,
label: 'silverfish', resolved: { file: model } };
h.loadImportTextures(row, [source], io);
assert.equal(row.defaultTexture, 'minecraft:textures/entity/silverfish/silverfish.png');
assert.deepEqual(Array.from(row.textures.keys()), [row.defaultTexture]);
const plan = { parsed: { animationId: 'example' }, rows: [row], definition: { actors: [{}] },
imported: { stageNames: [], fileObj: { animations: {} } }, blockEntry: null };
h.commitAutomaticImport(plan);
assert.equal(h.context.Texture.all.length, 1);
row.json.afw_bone_textures = { body: 'minecraft:textures/entity/silverfish.png' };
h.loadImportTextures(row, [source], io);
assert.deepEqual(Array.from(row.textures.keys()), [row.defaultTexture, 'minecraft:textures/entity/silverfish.png']);
});
test('complete import uses the matching Alex skin as a player-only texture fallback', async (t) => {
const f = fixtures(t), source = f.makePack('source');
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'player', entity_types: ['minecraft:player'] }], stages: [{ stage: 1 }],
});
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
animations: { p1_player: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } } },
});
f.model(source, 'test:source/entity/player.m');
f.model(source, 'test:source/entity/player_slim.m');
const cache = path.join(f.root, 'cached-assets');
const png = Buffer.alloc(24);
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
png.writeUInt32BE(64, 16); png.writeUInt32BE(64, 20);
for (const shape of ['wide', 'slim']) {
const image = path.join(cache, `assets/minecraft/textures/entity/player/${shape}/alex.png`);
fs.mkdirSync(path.dirname(image), { recursive: true });
fs.writeFileSync(image, png);
}
for (const slim of [false, true]) {
const h = harness(true);
h.storage.set('animationdirector_prop_source', cache);
h.context.selectedFile = { path: defFile };
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models')
? { ...result, slim_0: slim } : result;
await h.automaticallyImportAnimation();
assert.deepEqual(h.errors, []);
const row = h.context.importedPlan.rows[0];
assert.equal(row.defaultTexture, `minecraft:textures/entity/player/${slim ? 'slim' : 'wide'}/alex.png`);
assert.equal(row.textures.get(row.defaultTexture).dimensions.width, 64);
}
});
test('definition actors exclude extra clips, their effects and stages containing only unused actors', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
});
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: {
p1_unused: { animation_length: 99, bones: { discarded: { rotation: [1, 2, 3] } },
sound_effects: { '0': { effect: 'discarded_sound' } }, particle_effects: { '0': { effect: 'discarded_particle' } } },
p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } },
p2_unused: { animation_length: 99, bones: {} },
} });
f.model(source, 'test:source/entity/wolf.m');
h.context.selectedFile = { path: file };
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models') ? { ...result, gender_0: 'm' } : result;
await h.automaticallyImportAnimation();
assert.deepEqual(h.errors, []);
const plan = h.context.importedPlan;
assert.equal(plan.rows.length, 1);
assert.deepEqual(Array.from(plan.parsed.actorLabels), ['wolf']);
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']);
const clip = plan.imported.fileObj.animations['example.p1'];
assert.equal(clip.animation_length, 1);
assert.deepEqual(Object.keys(clip.bones), ['actor1_body']);
assert.equal(clip.sound_effects, undefined);
assert.equal(clip.particle_effects, undefined);
assert.ok(h.messages.some((message) => /Ignored animation actors.*unused/.test(message)));
});
test('a definition actor without a distinct clip still requires a valid mapping', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] },
{ label: 'player', entity_types: ['minecraft:player'] }], stages: [{ stage: 1 }],
});
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
animations: { p1_wolf: { animation_length: 1, bones: {} } },
});
h.context.selectedFile = { path: file };
await h.automaticallyImportAnimation();
assert.match(h.errors[0], /Each definition actor must be assigned a distinct animation actor/);
assert.equal(h.context.importedPlan, undefined);
assert.equal(h.context.Project.uuid, 'original');
});
test('automatic import retains use_stage metadata without duplicating editor animations', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }],
stages: [{ stage: 1, escapable: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }],
});
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: {
p1_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } },
sound_effects: { '0': { effect: 'example' } } },
} });
f.model(source, 'test:source/entity/wolf.m');
h.context.selectedFile = { path: file };
await h.automaticallyImportAnimation();
assert.deepEqual(h.errors, []);
const plan = h.context.importedPlan;
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']);
const clips = plan.imported.fileObj.animations;
assert.equal(clips['example.p2'], undefined);
assert.ok(clips['example.p1'].bones.actor1_body);
assert.equal(plan.definition.stages[1].use_stage, 1);
assert.equal(plan.definition.stages[1].speed, 0.8);
assert.equal(plan.definition.stages[1].non_peak, true);
assert.equal(plan.definition.stages[1].escapable, false);
});
test('playback references require declared stages and physical clips, not recursive aliases', () => {
const h = harness();
const parsed = { stages: [{ stageNumber: 1, entries: [{ actorLabel: 'wolf', key: 'p1_wolf', animObj: { bones: {} } }] }] };
assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 9 }] }), /missing playback stage p9/);
assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1 }, { stage: 1 }] }), /unique positive/);
assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 2 }, { stage: 2, use_stage: 1 }] }), /requires animation clips.*p2/);
});
test('canceling actor review leaves the open project and saved folders unchanged', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
});
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
animations: { p1_wolf: { animation_length: 1, bones: {} } },
});
h.context.selectedFile = { path: file };
h.context.answer = () => false;
await h.automaticallyImportAnimation();
assert.equal(h.context.Project.uuid, 'original');
assert.equal(h.context.importedPlan, undefined);
assert.equal(h.storage.size, 0);
});
test('missing models can be supplied manually, and their locally supplied textures are not downloaded', async (t) => {
const f = fixtures(t), h = harness(true), source = f.makePack('source');
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
});
f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), {
animations: { p1_wolf: { animation_length: 1, bones: { body: { position: [1, 2, 3] } } } },
});
const model = f.model(source, 'test:unrelated/wolf.m', { afw_texture: 'test:textures/unavailable.png' });
h.context.selectedFile = { path: file };
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models')
? { ...result, gender_0: 'm', model_0: { path: model } } : result;
await h.automaticallyImportAnimation();
assert.deepEqual(h.errors, []);
assert.equal(h.context.importedPlan.rows[0].resolved.kind, 'Manually selected');
assert.equal(h.context.importedPlan.rows[0].textures.size, 0);
assert.ok(h.messages.some((message) => /Imported with notes:.*no base texture/.test(message)));
});
test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => {
const h = harness();
h.storage.set('animationdirector_sound_source', path.resolve(__dirname, '../../NON/src/main/resources'));
const plan = { parsed: { animationId: 'example' }, blockEntry: null,
propEntries: { '["example","wolf","right",0]': { animation: 'example', item: 'minecraft:carrot', mode: 'item' } },
definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] },
imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': {
bones: { actor1_body: { rotation: [1, 2, 3] } }, sound_effects: { '0.5': { effect: 'reactiveimpact' } },
} } } },
rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } },
geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png',
textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] };
h.commitAutomaticImport(plan);
assert.equal(h.context.Project.uuid, 'new-project');
assert.equal(h.context.Group.all[0].name, 'actor1_body');
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'finish']);
assert.equal(h.undo[1].aspects.automatic_animation_import, true);
assert.equal(h.undo[1].aspects.groups.length, 1);
const override = JSON.parse(h.context.Project.multiactor_bone_texture_overrides);
assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png');
assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example);
assert.equal(JSON.parse(h.context.Project.animationdirector_prop_previews)['["example","wolf","right",0]'].item,'minecraft:carrot');
const cue = h.context.Animation.all[0].animators.effects.sound[0].data_points[0];
assert.equal(cue.effect, 'reactiveimpact');
assert.equal(cue.file, 'blob:import-preview');
});
test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => {
const h = harness();
h.context.Animator.loadFile = () => { throw new Error('load failed'); };
assert.throws(() => h.commitAutomaticImport({ parsed: { animationId: 'example' }, rows: [],
definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/);
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
});
test('manual replacement rejects duplicate and cyclic bones before touching the old actor', () => {
const h = harness(), original = new h.context.Group({ name: 'actor1_body' }); original.init();
for (const bones of [[{ name: 'body' }, { name: 'body' }], [{ name: 'body', parent: 'head' }, { name: 'head', parent: 'body' }]]) {
assert.throws(() => h.importGeometryIntoCurrentProject({ bones }, 1, true), /unique|Cyclic/);
assert.equal(h.context.Group.all[0], original);
assert.equal(h.undo.length, 0);
}
});
test('manual geometry import cancels its undo transaction when model creation fails', () => {
const h = harness();
h.context.Group.prototype.init = () => { throw new Error('model creation failed'); };
assert.throws(() => h.importGeometryIntoCurrentProject({ bones: [{ name: 'body' }] }, 1), /model creation failed/);
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
});
test('import asset lookup opens sources lazily and reuses positive and negative results', (t) => {
const f = fixtures(t), h = harness(), first = f.makePack('first'), second = f.makePack('second');
const relative = 'assets/test/textures/demo.png', png = Buffer.alloc(24);
Buffer.from('89504e470d0a1a0a', 'hex').copy(png); png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
const file = path.join(first.root, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, png);
let secondOpened = 0, textureReads = 0;
const counted = { path, fs: { ...fs, statSync(file, ...args) {
if (file === second.root) secondOpened++;
return fs.statSync(file, ...args);
}, readFileSync(file, ...args) { if (file.endsWith('demo.png')) textureReads++; return fs.readFileSync(file, ...args); } } };
const assets = h.createImportAssetLookup(counted, [first.root, second.root]);
assert.equal(assets.read(relative).dimensions.width, 32);
assert.equal(secondOpened, 0);
assets.read(relative); assert.equal(textureReads, 1);
assert.equal(assets.read('assets/test/textures/missing.png'), null);
const opened = secondOpened;
assert.equal(assets.read('assets/test/textures/missing.png'), null);
assert.equal(secondOpened, opened);
});
+609
View File
@@ -0,0 +1,609 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function harness() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.blockTest = { readBlockRequirementForm, buildBlockRequirementGuideBoxes, stripBlockRequirementGuideBones, showAnimationDefinitionReviewDialog, rebuildBlockRequirementGuides, getAuthoredBlockRequirement, compileGeckoModelJson, createBlockRequirementPanel, hideInternalBlockGuideFaces, refreshBlockGuideFaces, prepareAnimationDefinitionImport, applyAnimationDefinitionImport, importAnimationDefinition, getSavedAnimdef, getPanel: () => blockRequirementPanel };\n Plugin.register(PLUGIN_ID, {'
);
const project = { uuid: 'project-one', multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
const dialogs = [];
const exports = [];
const errors = [];
const edits = [];
const events = new Map();
const previewEvents = new Map();
const timers = new Map();
const previewUpdates = [];
const menus = [];
let sequence = 0;
class Group {
static all = [];
constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); }
init() { Group.all.push(this); return this; }
addTo(parent) { this.parent = parent; if (parent instanceof Group) parent.children.push(this); }
remove() {
for (const child of this.children.slice()) child.remove();
Group.all = Group.all.filter((group) => group !== this);
}
}
class Cube {
static all = [];
static preview_controller = {
on(names, listener) { for (const name of names.split(' ')) previewEvents.set(name, listener); },
};
constructor(data) {
Object.assign(this, data, { uuid: `cube-${++sequence}`, mesh: {},
faces: Object.fromEntries(['north', 'south', 'east', 'west', 'up', 'down'].map((name) => [name, { texture: false }])) });
}
init() { Cube.all.push(this); }
addTo(parent) { this.parent = parent; parent.children.push(this); }
getAllAncestors() { return [this.parent]; }
remove() { Cube.all = Cube.all.filter((cube) => cube !== this); }
}
const context = {
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
Toggle: class { constructor(id, options) { Object.assign(this, options, { id, value: false }); } },
Timeline: { loop() {} },
Panel: class {
constructor(id, options) {
this.options = options;
this.vue = Object.assign(options.component.data(), options.component.methods);
this.vue.$set = (object, key, value) => { object[key] = value; };
}
},
setTimeout(callback) { const id = ++sequence; timers.set(id, callback); return id; },
clearTimeout(id) { timers.delete(id); },
MenuBar: { menus: { tools: { addAction(menu) { menus.push(menu); } } }, update() {} },
THREE: { MeshBasicMaterial: class { constructor(options) { Object.assign(this, options); } }, DoubleSide: 2 },
Canvas: {
updateAll() {
for (const cube of Cube.all) {
cube.mesh.material = 'selected-shared-texture';
previewEvents.get('update_faces')?.({ element: cube });
}
},
updateAllFaces() { this.updateAll(); },
updateView(options) {
previewUpdates.push(options);
for (const cube of options.elements || []) {
cube.mesh.material = 'selected-shared-texture';
if (options.element_aspects?.faces) previewEvents.get('update_faces')?.({ element: cube });
}
},
},
Undo: {
initEdit(aspects) { edits.push({ before: project.animationdirector_block_requirements, aspects }); },
finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); },
},
Dialog: class { constructor(options) { dialogs.push(options); } show() {} },
Blockbench: { isWeb: false, export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); },
addCSS() { return { delete() {} }; },
showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } },
Buffer, requireNativeModule: require, console,
};
vm.runInNewContext(source, context);
return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, previewUpdates, menus, Group, Cube };
}
function wallForm(overrides = {}) {
return { type: 'wall', blocks: '#minecraft:fences', height_min: 3, height_max: '', width_min: 2, width_max: '',
facing: '', clearance_width: 1, clearance_height: 2, clearance_depth: 1, wall_thickness: 1, ...overrides };
}
function importAnalysis() {
return { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 },
{ stageNumber: 2, name: 'test.p2', loop: false, cycleSeconds: 2 }], warnings: [] };
}
test('imported beds and fences receive their editor-only preview dimensions', () => {
const cases = [
['center_support', '#minecraft:beds', 0.5625],
['center_support', 'minecraft:red_bed', 0.5625],
['center_support', ['minecraft:white_bed', 'minecraft:blue_bed'], 0.5625],
['wall', '#minecraft:fences', 0.25],
['wall', 'minecraft:oak_fence', 0.25],
['wall', 'minecraft:nether_brick_fence', 0.25],
['wall', 'minecraft:oak_fence_gate', 1],
['wall', ['minecraft:oak_fence', 'minecraft:stone'], 1],
];
for (const [type, blocks, expected] of cases) {
const h = harness();
const requirement = type === 'wall' ? { type, blocks, height: { min: 1 }, width: { min: 1 } }
: { type, blocks, support: 'surface', placement: 'surface_footprint' };
const source = { actors: [{ label: 'player' }, { label: 'wolf' }], stages: [{ stage: 1 }, { stage: 2 }],
block_requirements: requirement };
h.project.animationdirector_block_requirements = JSON.stringify({ test: {
requirement: { type: 'wall', blocks: 'minecraft:stone' }, preview: { surfaceHeight: 1.5, wallThickness: 0.75 } } });
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source);
const entry = h.getAuthoredBlockRequirement('test');
assert.equal(type === 'wall' ? entry.preview.wallThickness : entry.preview.surfaceHeight, expected);
assert.equal(h.getSavedAnimdef('test').block_requirements.preview, undefined);
if (type === 'center_support') {
const surface = h.buildBlockRequirementGuideBoxes(entry).find((box) => box.kind === 'required');
assert.ok(surface);
}
}
});
test('desktop-only tools are visible in desktop Blockbench and hidden in browser Blockbench', () => {
const h = harness(); h.context.registration.onload();
const actions = h.menus.flatMap((menu) => menu.children).filter((action) => action && typeof action !== 'string');
const ids = ['animationdirector_automatic_import', 'animationdirector_pack_folders', 'animationdirector_item_props',
'animationdirector_sound_library', 'animationdirector_create_jem_model'];
for (const id of ids) {
const action = actions.find((entry) => entry.id === id);
assert.ok(action, `${id} is registered`);
assert.equal(action.condition(), true, `${id} is visible on desktop`);
h.context.Blockbench.isWeb = true;
assert.equal(action.condition(), false, `${id} is hidden in browsers`);
h.context.Blockbench.isWeb = false;
}
});
test('animation menu orders import and creation actions and has no deprecated split-file tools', () => {
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 === '_' ? '_' : action.name), [
'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
'_', 'Actor Preferences', 'Replace Actor Model',
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
'_', 'Animation Definition', 'Export Animations',
]);
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
});
test('import prefills generator fields, builds guides and retains fields without UI controls on export', () => {
const h = harness();
const source = {
display_name: 'Imported animation', animators: ['Animator'], content_tags: ['test'],
system_tags: ['manualpeak'], weight: 2.5, required_union_tags: ['example.tag'],
join_after: ['example:previous'], match_content_on_join: true, attack_eligible: false,
liquid_gain_multiplier: 0.5, manual_peak: { priority: 5 }, speed: 1.5,
water: 'surface', position_anchor_actor: 'wolf',
camera: { torso_tracking: 'continuous', block_requirement_collision: 'ignore' },
addon_data: { example: { flag: true } }, escapable: false,
actors: [
{ label: 'player', entity_types: ['minecraft:player'], activity: 'passive', receiver: true, age: 'adult' },
{ label: 'wolf', entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'], activity: 'active',
injector: 'V', injects_into: 'player', held_item: { items: ['#minecraft:swords'], hand: 'either', prop_slot: 'right' } },
],
stages: [
{ stage: 1, loop: true, cycle_seconds: 0.75, allow_join: false, escapable: true,
speed: 1.25, stage_duration_multiplier: 0.5, cycle_midpoint_offset_seconds: 0.1,
props: { right: { item: 'minecraft:carrot' } } },
{ stage: 2, loop: false, cycle_seconds: 1.75, allow_join: true, escapable: false, non_peak: true },
],
block_requirements: { type: 'wall', blocks: '#minecraft:fences', height: { min: 3 },
width: { min: 2, max: 2, both_axes: true }, facing: 'front', clearance: { width: 1, height: 2, depth: 1 } },
};
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source);
const dialog = h.dialogs.at(-1);
const values = Object.fromEntries(Object.entries(dialog.form)
.filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value]));
assert.equal(values.display_name, source.display_name);
assert.equal(values.animators, 'Animator');
assert.equal(values.weight, 2.5);
assert.equal(values.system_tags, 'manualpeak');
assert.equal(values.manual_peak_priority, 5);
assert.equal(values.stub_actor_1_receiver, true);
assert.equal(values.stub_actor_2_injector, 'V');
assert.deepEqual(Array.from(values.stub_actor_2_injects_into), ['player']);
assert.equal(values.stub_actor_2_held_items, '#minecraft:swords');
assert.equal(values.stub_actor_2_held_hand, 'either');
assert.equal(values.stub_stage_1_duration_multiplier, 0.5);
assert.equal(values.stub_stage_1_cycle_seconds, 0.75);
assert.equal(values.stub_stage_2_peaked, true);
assert.ok(h.Cube.all.length > 0);
assert.equal(h.edits.length, 1);
assert.equal(h.edits[0].action, 'Import animation definition');
assert.equal(h.edits[0].aspects.animdef_edit, true);
assert.equal(dialog.onConfirm(values), true);
const exported = h.exports.at(-1);
assert.deepEqual(exported.stages[0].props, source.stages[0].props);
assert.deepEqual(exported.addon_data, source.addon_data);
assert.equal(exported.actors[0].age, 'adult');
assert.equal(exported.actors[1].injects_into, 'player');
assert.equal(exported.block_requirements.width.both_axes, true);
assert.equal(h.errors.length, 0);
});
test('importing an animdef replaces saved fields instead of retaining old settings', () => {
const h = harness();
h.project.multiactor_animdef_animators = 'Old author';
h.project.animationdirector_animdef_definitions = JSON.stringify({ test: {
display_name: 'Old', weight: 8, content_tags: ['old'], addon_data: { old: true },
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
stages: [{ stage: 1, non_peak: true }, { stage: 2 }],
}, other: { display_name: 'Unchanged' } });
const replacement = { display_name: 'Replacement',
actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }],
stages: [{ stage: 1 }, { stage: 2, non_peak: true }],
};
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), replacement);
const saved = h.getSavedAnimdef('test');
assert.equal(saved.display_name, 'Replacement');
assert.equal(saved.weight, undefined);
assert.equal(saved.content_tags, undefined);
assert.equal(saved.addon_data, undefined);
assert.equal(h.getSavedAnimdef('other').display_name, 'Unchanged');
const dialog = h.dialogs.at(-1);
assert.equal(dialog.form.stub_stage_1_peaked.value, false);
assert.equal(dialog.form.stub_stage_2_peaked.value, true);
assert.equal(dialog.form.animators.value, '');
});
test('import maps explicit labels before positional fallbacks and updates actor references', () => {
const h = harness();
const source = { actors: [{ label: 'wolf', injector: 'V', injects_into: 'bottom' },
{ label: 'bottom', receiver: true }], stages: [{ stage: 1, props: { bottom: { prop_right: 'minecraft:carrot' } } },
{ stage: 2 }], position_anchor_actor: 'bottom' };
const definition = h.prepareAnimationDefinitionImport(source, importAnalysis());
assert.equal(definition.actors[0].label, 'player');
assert.equal(definition.actors[0].receiver, true);
assert.equal(definition.actors[1].label, 'wolf');
assert.deepEqual(Array.from(definition.actors[1].injects_into), ['player']);
assert.equal(definition.position_anchor_actor, 'player');
assert.equal(definition.stages[0].props.player.prop_right, 'minecraft:carrot');
assert.equal(source.actors[1].label, 'bottom', 'Input JSON is not mutated');
const positional = h.prepareAnimationDefinitionImport({ actors: [{ receiver: true }, { injector: 'V' }],
stages: [{ stage: 1 }, { stage: 2 }] }, importAnalysis());
assert.equal(positional.actors[0].label, 'player');
assert.equal(positional.actors[1].label, 'wolf');
});
test('manual definition import embeds item prop previews in the same undoable edit', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-manual-props-'));
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
const write=(relative,data)=>{ const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true});
fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data)); };
write('assets/minecraft/items/carrot.json',{model:{type:'minecraft:model',model:'minecraft:item/carrot'}});
write('assets/minecraft/models/item/carrot.json',{parent:'minecraft:builtin/generated',textures:{layer0:'minecraft:item/carrot'}});
write('assets/minecraft/textures/item/carrot.png','image');
h.applyAnimationDefinitionImport({id:'test'},importAnalysis(),{
actors:[{label:'player',prop_right:'minecraft:carrot'},{label:'wolf'}],
stages:[{stage:1},{stage:2,props:{player:{prop_right:null}}}],
},[root]);
const previews=JSON.parse(h.project.animationdirector_prop_previews);
assert.equal(previews['["test","player","right",0]'].item,'minecraft:carrot');
assert.equal(previews['["test","player","right",2]'].mode,'hidden');
assert.equal(h.edits.at(-1).aspects.prop_preview_edit,true);
assert.equal(h.edits.at(-1).post.prop_preview_edit,true);
});
test('invalid imports leave definitions and guides unchanged', () => {
const h = harness();
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
const before = h.project.animationdirector_block_requirements;
const source = { actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }] };
for (const invalid of [null, { animations: {} }, { ...source, actors: [{}] },
{ ...source, stages: [{ stage: 1 }] }, { ...source, stages: [{ stage: 1 }, { stage: 1 }] },
{ ...source, actors: [{ label: 'same' }, { label: 'same' }] },
{ ...source, actors: [{ injects_into: 'unknown' }, {}] },
{ ...source, content_tags: 'wrong' },
{ ...source, block_requirements: { type: 'wall', width: { min: 2, max: 1 } } }]) {
assert.throws(() => h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), invalid));
}
assert.equal(h.project.animationdirector_block_requirements, before);
assert.equal(h.project.animationdirector_animdef_definitions, undefined);
assert.equal(h.edits.length, 1);
});
test('import without a requirement removes old guides and undo restores definition metadata too', () => {
const h = harness();
h.context.registration.onload();
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
const before = {};
h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true, animdef_edit: true } });
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), {
actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }], animation_tags: ['defeat'] });
assert.equal(h.Cube.all.length, 0);
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
assert.deepEqual(Array.from(h.getSavedAnimdef('test').system_tags), ['defeat']);
assert.equal(h.dialogs.at(-1).form.stub_stage_2_peaked.value, false, 'Missing peak flags are not invented');
const after = {};
h.events.get('create_undo_save')({ save: after, aspects: { block_requirement_edit: true, animdef_edit: true } });
h.events.get('load_undo_save')({ save: before });
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall');
assert.equal(Object.keys(h.getSavedAnimdef('test')).length, 0);
h.events.get('load_undo_save')({ save: after });
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
assert.equal(h.getSavedAnimdef('test').actors.length, 2);
});
test('building, editing, refreshing and removing guides never resets actor or folder texture overrides', () => {
const h = harness();
const actor = new h.Group({ name: 'actor1_body' });
actor.init(); actor.addTo('root');
const cubes = ['actor-texture', 'folder-override-texture'].map((material, index) => {
const cube = new h.Cube({ name: `actor1_cube${index}`, from: [0, 0, 0], to: [16, 16, 16] });
cube.init(); cube.addTo(actor);
cube.mesh.material = material;
for (const face of Object.values(cube.faces)) face.texture = material;
return cube;
});
const before = cubes.map((cube) => ({ material: cube.mesh.material, faces: JSON.stringify(cube.faces) }));
h.context.registration.onload();
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm({ width_min: 3, clearance_depth: 2 })));
h.refreshBlockGuideFaces();
h.rebuildBlockRequirementGuides('test', null);
assert.deepEqual(cubes.map((cube) => ({ material: cube.mesh.material, faces: JSON.stringify(cube.faces) })), before);
for (const update of h.previewUpdates) {
assert.ok(update.elements.every((cube) => !cubes.includes(cube)));
assert.ok((update.groups || []).every((group) => group !== actor));
}
assert.equal(h.previewUpdates.length, 4);
});
test('guide cubes omit internal faces, preserve colored boundaries and restore newly exposed faces', () => {
const h = harness();
const make = (kind, from, to) => new h.Cube({ name: `__ad_block_requirement_${kind}_test`, from, to });
const left = make('required', [0, 0, 0], [16, 16, 16]);
const right = make('required', [16, 0, 0], [32, 16, 16]);
const clearance = make('clearance', [32, 0, 0], [48, 16, 16]);
h.hideInternalBlockGuideFaces([left, right, clearance]);
assert.equal(left.faces.east.texture, null);
assert.equal(right.faces.west.texture, null);
assert.equal(left.faces.west.texture, false);
assert.equal(right.faces.east.texture, false, 'Blue/red boundaries remain visible');
assert.equal(clearance.faces.west.texture, false);
h.hideInternalBlockGuideFaces([left]);
assert.equal(left.faces.east.texture, false, 'Removing a neighbor exposes its former shared face');
});
test('several neighbors can cover one larger guide face without hiding partially exposed surfaces', () => {
const h = harness();
const make = (from, to) => new h.Cube({ name: '__ad_block_requirement_clearance_test', from, to });
const tall = make([0, 0, 0], [16, 32, 16]);
const lower = make([16, 0, 0], [32, 16, 16]);
const upper = make([16, 16, 0], [32, 32, 16]);
h.hideInternalBlockGuideFaces([tall, lower, upper]);
assert.equal(tall.faces.east.texture, null);
assert.equal(lower.faces.west.texture, null);
assert.equal(upper.faces.west.texture, null);
assert.equal(lower.faces.up.texture, null);
assert.equal(upper.faces.down.texture, null);
h.hideInternalBlockGuideFaces([tall, lower]);
assert.equal(tall.faces.east.texture, false, 'The upper exposed half must not disappear');
assert.equal(lower.faces.up.texture, false);
});
test('generated and reloaded guides remove their shared faces without affecting another animation', () => {
const h = harness();
h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm()));
const firstFaces = h.Cube.all.map((cube) => JSON.stringify(cube.faces));
assert.ok(h.Cube.all.some((cube) => Object.values(cube.faces).some((face) => face.texture === null)));
h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm()));
assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces);
for (const cube of h.Cube.all) for (const face of Object.values(cube.faces)) face.texture = false;
h.refreshBlockGuideFaces();
assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces);
});
test('wall data matches NoN minimum/maximum/facing syntax and ignores inactive fields', () => {
const h = harness();
const entry = h.readBlockRequirementForm(wallForm({ width_max: '2', height_max: '3', both_axes: true,
include_obstructions: true, facing: 'front', surface_radius: 9, footprint_width: 9 }));
assert.deepEqual(JSON.parse(JSON.stringify(entry.requirement)), {
type: 'wall', blocks: '#minecraft:fences', height: { min: 3, max: 3 },
width: { min: 2, max: 2, both_axes: true, include_obstructions: true }, facing: 'front',
clearance: { width: 1, height: 2, depth: 1 },
});
assert.equal(h.readBlockRequirementForm(wallForm({ both_axes: true })).requirement.width.both_axes, undefined);
assert.throws(() => h.readBlockRequirementForm(wallForm({ width_max: 1 })), /Maximum width/);
assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: '', facing: 'front' })), /facing requirement/);
assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: 'fence' })), /Invalid block/);
});
test('wall guides center an even span and reproduce side padding, anchor distance and required floors', () => {
const h = harness();
const entry = h.readBlockRequirementForm(wallForm({ clearance_width: 2 }));
const boxes = h.buildBlockRequirementGuideBoxes(entry);
const walls = boxes.filter((box) => box.label === 'wall');
assert.equal(walls.length, 6);
assert.equal(Math.min(...walls.map((box) => box.from[0])), -16);
assert.equal(Math.max(...walls.map((box) => box.to[0])), 16);
assert.equal(walls[0].from[2], -24);
assert.equal(walls[0].to[2], -8);
const clearance = boxes.filter((box) => box.kind === 'clearance');
assert.equal(clearance.length, 8);
assert.equal(Math.min(...clearance.map((box) => box.from[0])), -32);
assert.equal(Math.max(...clearance.map((box) => box.to[0])), 32);
assert.equal(clearance[0].from[2], -8);
assert.equal(boxes.filter((box) => box.label === 'floor').length, 6);
const thin = h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ wall_thickness: 0.25 })));
assert.equal(thin.find((box) => box.label === 'wall').from[2], -12);
assert.equal(thin.find((box) => box.kind === 'clearance').from[2], -2);
});
test('center footprint supports fractional heights and clearance margins without directional fields', () => {
const h = harness();
const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface',
blocks: '#minecraft:beds', placement: 'surface_footprint', footprint_width: 2, footprint_depth: 2,
footprint_height: 2, footprint_margin: 1, surface_height: 0.5625 });
assert.equal(entry.requirement.clearance, undefined);
assert.equal(entry.requirement.height, undefined);
const boxes = h.buildBlockRequirementGuideBoxes(entry);
assert.equal(boxes.filter((box) => box.kind === 'required').length, 4);
assert.equal(boxes.filter((box) => box.kind === 'clearance').length, 32);
assert.equal(boxes.find((box) => box.label === 'support').to[1], 9);
assert.equal(Math.min(...boxes.filter((box) => box.kind === 'clearance').map((box) => box.from[0])), -24);
assert.throws(() => h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface',
placement: 'directional_clearance', surface_radius: 0, blocks: '' }), /requires a block/);
});
test('directional center guides union overlapping above-surface checks and enforce preview size limits', () => {
const h = harness();
const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'slab',
placement: 'directional_clearance', surface_radius: 1 });
const boxes = h.buildBlockRequirementGuideBoxes(entry);
const front = boxes.filter((box) => box.kind === 'clearance' && box.from[0] === -8 && box.from[2] === 8);
assert.equal(front.length, 1);
assert.equal(front[0].from[1], 0);
assert.equal(front[0].to[1], 40);
assert.throws(() => h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ width_min: 32,
clearance_width: 32, clearance_depth: 32, clearance_height: 32 }))), /4096/);
});
test('editing replaces only target guides, persists metadata and removal overrides previously exported requirements', () => {
const h = harness();
h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm()));
h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm()));
const second = h.Group.all.find((group) => group.name.endsWith('second'));
const first = h.Group.all.find((group) => group.name.endsWith('first'));
const firstCube = h.Cube.all[0];
h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm({ width_min: 1 })));
assert.equal(h.Group.all.length, 2);
assert.ok(h.Group.all.includes(first));
assert.ok(h.Cube.all.includes(firstCube));
assert.ok(h.Group.all.includes(second));
assert.ok(h.Cube.all.every((cube) => cube.export === false));
assert.ok(h.Group.all.every((group) => group.export === false));
assert.equal(h.getAuthoredBlockRequirement('first').requirement.width.min, 1);
assert.equal(h.edits.at(-1).aspects.block_requirement_edit, true);
assert.equal(h.edits.at(-1).post.block_requirement_edit, true);
h.rebuildBlockRequirementGuides('first', null);
assert.equal(h.Group.all.length, 1);
assert.equal(h.getAuthoredBlockRequirement('first').requirement, null);
});
test('live panel coalesces input bursts and leaves invalid edits out of the project and undo history', () => {
const h = harness();
h.createBlockRequirementPanel();
const view = h.getPanel().vue;
view.load('test');
assert.equal(h.edits.length, 0, 'Opening the editor does not add a requirement');
view.flush(true);
const cube = h.Cube.all[0];
const field = view.form.clearance_depth;
view.input('clearance_depth', field, { target: { value: '2' } });
view.input('clearance_depth', field, { target: { value: '3' } });
assert.equal(h.timers.size, 1);
assert.equal(h.edits.length, 1);
for (const callback of h.timers.values()) callback();
assert.equal(h.timers.size, 0);
assert.equal(h.edits.length, 2, 'The burst creates one undo entry');
assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3);
assert.ok(h.Cube.all.includes(cube));
view.input('clearance_depth', field, { target: { value: '' } });
view.flush();
assert.match(view.error, /last valid preview/);
assert.equal(h.timers.size, 0);
assert.equal(h.edits.length, 2);
assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3);
view.input('clearance_depth', field, { target: { value: '3' } });
view.flush();
assert.equal(view.error, '');
assert.equal(h.edits.length, 2, 'Unchanged values do not add undo entries');
});
test('panel switches animations safely, resets on project changes and reloads restored undo metadata', () => {
const h = harness();
h.context.registration.onload();
h.createBlockRequirementPanel();
const view = h.getPanel().vue;
view.load('first');
view.input('clearance_depth', view.form.clearance_depth, { target: { value: '4' } });
view.selectId({ target: { value: 'second' } });
assert.equal(h.getAuthoredBlockRequirement('first').requirement.clearance.depth, 4);
assert.equal(view.animationId, 'second');
assert.equal(h.getAuthoredBlockRequirement('second'), null);
view.flush(true);
const before = h.project.animationdirector_block_requirements;
view.input('clearance_depth', view.form.clearance_depth, { target: { value: '5' } });
view.flush();
h.events.get('load_undo_save')({ save: { animationdirector_block_requirements: before } });
h.events.get('undo')();
assert.equal(view.values.clearance_depth, 1);
view.input('clearance_depth', view.form.clearance_depth, { target: { value: '7' } });
h.context.Project = { uuid: 'another-project' };
h.events.get('select_project')();
assert.equal(h.timers.size, 0);
assert.equal(view.animationId, '');
assert.equal(h.context.Project.animationdirector_block_requirements, undefined);
assert.equal(JSON.parse(h.project.animationdirector_block_requirements).second.requirement.clearance.depth, 1);
});
test('panel conditionally exposes wall and center-support sections without exporting preview settings', () => {
const h = harness();
h.createBlockRequirementPanel();
const panel = h.getPanel();
const view = panel.vue;
view.load('test');
const titles = () => Array.from(panel.options.component.computed.sections.call(view), (section) => section.title);
assert.ok(titles().includes('Wall structure'));
assert.ok(!titles().includes('Center support'));
view.values.type = 'center_support';
view.values.placement = 'surface_footprint';
assert.ok(titles().includes('Center support'));
assert.ok(!titles().includes('Wall structure'));
assert.ok(!titles().includes('Directional clearance'));
view.flush(true);
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'center_support');
view.remove();
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
assert.equal(h.Cube.all.length, 0);
});
test('guide material survives face rebuilds and metadata participates in Blockbench undo saves', () => {
const h = harness();
h.context.registration.onload();
const before = {};
h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true } });
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
const post = {};
h.events.get('create_undo_save')({ save: post, aspects: { block_requirement_edit: true } });
for (const kind of ['required', 'clearance']) {
const cube = h.Cube.all.find((candidate) => candidate.name.includes(`_${kind}_`));
assert.equal(cube.mesh.material.opacity, 0.22);
assert.equal(cube.mesh.material.transparent, true);
cube.mesh.material = {};
h.previewEvents.get('update_faces')({ element: cube });
assert.equal(cube.mesh.material.opacity, 0.22);
}
h.events.get('load_undo_save')({ save: before });
assert.equal(h.getAuthoredBlockRequirement('test'), null);
h.events.get('load_undo_save')({ save: post });
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall');
});
test('generator includes only the selected animation requirement and strips guide bones from exports', () => {
const h = harness();
const entry = h.readBlockRequirementForm(wallForm());
h.rebuildBlockRequirementGuides('test', entry);
const analysis = { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 }], warnings: [] };
function generate(id) {
h.showAnimationDefinitionReviewDialog({ id }, analysis);
const dialog = h.dialogs.at(-1);
const values = Object.fromEntries(Object.entries(dialog.form)
.filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value]));
assert.equal(dialog.onConfirm(values), true);
return h.exports.at(-1);
}
assert.deepEqual(generate('test').block_requirements, JSON.parse(JSON.stringify(entry.requirement)));
assert.equal(generate('other').block_requirements, undefined);
h.rebuildBlockRequirementGuides('test', null);
assert.equal(generate('test').block_requirements, undefined);
assert.equal(h.errors.length, 0);
const clip = { bones: { __ad_block_requirement_test: { rotation: [1, 2, 3] }, actor1_body: { position: [0, 1, 2] } }, sound_effects: { 0: 'sound' } };
h.stripBlockRequirementGuideBones(clip);
assert.deepEqual(Object.keys(clip.bones), ['actor1_body']);
assert.ok(clip.sound_effects);
h.context.Format = { codec: { compile: () => ({ 'minecraft:geometry': [{ bones: [
{ name: '__ad_block_requirement_test' }, { name: 'renamed_guide', parent: '__ad_block_requirement_test' },
{ name: 'actor1_body' },
] }] }) } };
h.context.Codecs = {};
assert.deepEqual(Array.from(h.compileGeckoModelJson()['minecraft:geometry'][0].bones, (bone) => bone.name), ['actor1_body']);
});
+235
View File
@@ -0,0 +1,235 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const zlib = require('node:zlib');
function harness() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {', `globalThis.props = { indexPropZip, propResourcePath, openPropSource,
loadPropAsset, resolvePropModel, propTextureId, propDisplayTransform, manualPropModel,
writePropDefinition, effectivePropEntry, effectivePropItem, propEntryKey, extrudePropPixels, discoverPropSources,
buildPropMesh, refreshPropPreviews, clearPropMeshes, propFaceVertices, propDefaultUv,
applyPropPreview, createPropDialog, showPropDialog, readPropPreviews, importPropPreviews };
Plugin.register(PLUGIN_ID, {`);
const context = { Buffer, requireNativeModule: require, console, Plugin: { register() {} },
Project: { uuid: 'test' }, Group: { all: [] }, Animation: { all: [] }, localStorage: { getItem() { return null; } } };
vm.runInNewContext(source, context);
return { ...context.props, context };
}
function source(entries) {
return { read(name) { const value = entries[name]; return value == null ? null : Buffer.from(typeof value === 'object' ? JSON.stringify(value) : value); } };
}
function zipFixture(name, content, compressed = false) {
const bytes = Buffer.from(content), data = compressed ? zlib.deflateRawSync(bytes) : bytes, filename = Buffer.from(name);
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(compressed ? 8 : 0, 8);
local.writeUInt32LE(data.length, 18); local.writeUInt32LE(bytes.length, 22); local.writeUInt16LE(filename.length, 26);
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(compressed ? 8 : 0, 10);
central.writeUInt32LE(data.length, 20); central.writeUInt32LE(bytes.length, 24); central.writeUInt16LE(filename.length, 28);
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10);
end.writeUInt32LE(central.length + filename.length, 12); end.writeUInt32LE(local.length + filename.length + data.length, 16);
return Buffer.concat([local,filename,data,central,filename,end]);
}
test('ZIP asset lookup supports stored/deflated entries and rejects corrupt/oversized archives', () => {
const h = harness(), name = 'assets/minecraft/models/item/example.json';
for (const deflate of [false,true]) assert.equal(h.indexPropZip(zipFixture(name,'{}',deflate)).read(name).toString(), '{}');
assert.throws(() => h.indexPropZip(Buffer.from('not a zip')), /Not a supported/);
const oversized = zipFixture(name,'{}');
oversized.writeUInt32LE(17000000, 30 + name.length + 2 + 24);
assert.throws(() => h.indexPropZip(oversized).read(name), /oversized/);
const broken = zipFixture(name,'{}'); broken.writeUInt32LE(0,0);
assert.throws(() => h.indexPropZip(broken).read(name), /Invalid asset entry/);
});
test('resource paths cannot escape assets and extracted sources resolve textures', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-props-'));
t.after(() => fs.rmSync(root,{ recursive:true,force:true }));
for (const id of ['minecraft:../private','../private','minecraft:item//foo','minecraft:/foo']) assert.throws(() => h.propResourcePath(id,'models','.json'));
fs.mkdirSync(path.join(root,'assets/minecraft/textures'),{recursive:true});
fs.writeFileSync(path.join(root,'assets/minecraft/textures/test.png'),'image');
assert.equal(h.openPropSource(path.join(root,'assets')).read('assets/minecraft/textures/test.png').toString(),'image');
});
test('generated models inherit textures/display and resolve modern item definitions', () => {
const h = harness();
const s = source({
'assets/minecraft/items/carrot.json': {model:{type:'minecraft:model',model:'minecraft:item/carrot'}},
'assets/minecraft/models/item/carrot.json': {parent:'minecraft:item/generated',textures:{layer0:'#actual',actual:'minecraft:item/carrot'}},
'assets/minecraft/models/item/generated.json': {parent:'builtin/generated',display:h.manualPropModel('generated').display},
'assets/minecraft/textures/item/carrot.png':'PNG',
});
const result = h.loadPropAsset(s,'minecraft:carrot','right');
assert.equal(result.parts.length,1);
assert.equal(result.parts[0].generated,true);
assert.deepEqual(Array.from(result.parts[0].transform.scale),[0.55,0.55,0.55]);
assert.equal(result.parts[0].png,'data:image/png;base64,UE5H');
});
test('left-hand and floor transforms follow Minecraft display contexts', () => {
const h = harness(), model = {display:{thirdperson_righthand:{rotation:[10,20,30],translation:[1,2,3],scale:[.4,.5,.6]},ground:{scale:[.5,.5,.5]}}};
const left = h.propDisplayTransform(model,'left');
assert.deepEqual(Array.from(left.rotation),[10,-20,-30]);
assert.deepEqual(Array.from(left.translation),[-1,2,3]);
assert.deepEqual(Array.from(h.propDisplayTransform(model,'floor').scale),[.5,.5,.5]);
const handheld = h.propDisplayTransform(h.manualPropModel('handheld'),'left');
assert.deepEqual(Array.from(handheld.rotation),[0,-90,55]);
assert.deepEqual(Array.from(handheld.translation),[0,4,0.5]);
const explicit = h.propDisplayTransform({display:{thirdperson_lefthand:{rotation:[5,35,-20],translation:[2,3,4]}}},'left');
assert.deepEqual(Array.from(explicit.rotation),[5,-35,20]);
assert.deepEqual(Array.from(explicit.translation),[-2,3,4]);
});
test('3D JSON models keep elements, UVs, rotations and texture aliases', () => {
const h = harness(), s = source({
'assets/minecraft/models/item/test.json': {textures:{all:'minecraft:block/stone'},elements:[{from:[0,0,0],to:[16,16,16],
rotation:{origin:[8,8,8],axis:'y',angle:22.5},faces:{north:{texture:'#all',uv:[0,0,16,16]}}}]},
'assets/minecraft/textures/block/stone.png':'image',
});
const result = h.loadPropAsset(s,'minecraft:test','floor');
assert.equal(result.parts[0].elements[0].faces.north.texture,'minecraft:block/stone');
assert.equal(result.parts[0].elements[0].rotation.angle,22.5);
});
test('conditional appearances use fallback, special renderers fail clearly, cycles are rejected', () => {
const h = harness(), entries = {
'assets/minecraft/items/test.json':{model:{type:'minecraft:condition',on_false:{type:'minecraft:model',model:'minecraft:item/test'}}},
'assets/minecraft/models/item/test.json':{parent:'builtin/generated',textures:{layer0:'minecraft:item/test'}},
'assets/minecraft/textures/item/test.png':'image',
};
assert.match(h.loadPropAsset(source(entries),'minecraft:test','right').warnings[0],/default branch/);
entries['assets/minecraft/items/test.json']={model:{type:'minecraft:special'}};
assert.throws(() => h.loadPropAsset(source(entries),'minecraft:test','right'),/manual texture/);
assert.throws(() => h.resolvePropModel(source({'assets/minecraft/models/item/a.json':{parent:'item/b'},
'assets/minecraft/models/item/b.json':{parent:'item/a'}}),'item/a'),/Cyclic/);
assert.throws(() => h.propTextureId('#a',{a:'#b',b:'#a'}),/Cyclic/);
});
test('default props, stage replacements, hidden slots and inheritance preserve unrelated metadata', () => {
const h = harness(), def = {actors:[{label:'player',injector:'V'},{label:'wolf'}],stages:[{stage:1},{stage:2,use_stage:1,props:null}]};
const defaults = h.writePropDefinition(def,'player','right',0,'item','minecraft:carrot');
assert.equal(defaults.actors[0].prop_right,'minecraft:carrot');
assert.equal(def.actors[0].prop_right,undefined);
const replace = h.writePropDefinition(defaults,'player','right',1,'item','minecraft:diamond_sword');
assert.equal(replace.stages[0].props.player.prop_right,'minecraft:diamond_sword');
const hidden = h.writePropDefinition(replace,'player','right',1,'hidden','');
assert.equal(hidden.stages[0].props.player.prop_right,null);
const inherit = h.writePropDefinition(hidden,'player','right',1,'inherit','');
assert.equal(inherit.stages[0].props,undefined);
const allHidden = h.writePropDefinition(defaults,'player','right',2,'item','minecraft:carrot');
assert.equal(allHidden.stages[1].props.wolf,null);
assert.equal(allHidden.stages[1].props.player.prop_left,null);
assert.equal(allHidden.stages[1].use_stage,1);
});
test('stage preview precedence supports hidden override and default fallback', () => {
const h = harness(), entries = {};
const base = {mode:'item'}, hidden = {mode:'hidden'};
entries[h.propEntryKey('test','player','right',0)] = base;
entries[h.propEntryKey('test','player','right',2)] = hidden;
assert.equal(h.effectivePropEntry(entries,'test','player','right',1),base);
assert.equal(h.effectivePropEntry(entries,'test','player','right',2),hidden);
const def = {actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[{stage:1},
{stage:2,props:{player:{prop_right:null}}},{stage:3,props:null},{stage:4,props:{player:{prop_right:'minecraft:stick'}}}]};
assert.equal(h.effectivePropItem(def,'player','right',1),'minecraft:carrot');
assert.equal(h.effectivePropItem(def,'player','right',2),null);
assert.equal(h.effectivePropItem(def,'player','right',3),null);
assert.equal(h.effectivePropItem(def,'player','right',4),'minecraft:stick');
});
test('sprite extrusion preserves holes and omits internal boundary faces', () => {
const h = harness(), pixels = new Uint8Array(3*3*4);
for(let i=0;i<9;i++) pixels[i*4+3] = i === 4 ? 0 : 255;
const elements = h.extrudePropPixels(3,3,pixels);
assert.equal(elements.length,9);
assert.equal(elements.slice(1).reduce((sum,e)=>sum+Object.keys(e.faces).length,0),16);
assert.throws(() => h.extrudePropPixels(512,512,new Uint8Array()),/65,536/);
});
test('asset discovery reuses Asset Browser cache without requiring the plugin', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-cache-'));
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
fs.mkdirSync(path.join(root,'minecraft_assets_cache'));
fs.writeFileSync(path.join(root,'minecraft_assets_cache','1.21.11.jar'),'jar');
h.context.SystemInfo = {user_data_directory:root};
const sources = h.discoverPropSources();
assert.equal(sources.length,1);
assert.equal(sources[0].label,'Asset Browser: 1.21.11');
});
test('Item Props uses native dialog form fields and Apply/Cancel actions', () => {
const h = harness();
let configuration;
h.context.Animation.selected = { name: 'test.p1' };
h.context.SystemInfo = { user_data_directory: path.join(os.tmpdir(), 'missing-prop-assets') };
h.context.Dialog = class {
constructor(options) { configuration = options; }
};
h.createPropDialog();
assert.equal(configuration.title,'Item Props');
assert.equal(configuration.width,760);
assert.deepEqual(Array.from(configuration.buttons),['Cancel','Apply prop']);
assert.equal(configuration.component,undefined);
assert.equal(configuration.form.item.type,'text');
assert.equal(configuration.form.source.type,'select');
assert.equal(configuration.form.jar.type,'file');
assert.equal(configuration.form.folder.type,'folder');
assert.equal(configuration.form.texture.type,'file');
assert.equal(configuration.form.item_search,undefined);
assert.equal(configuration.form.item_choice.type,'select');
});
test('item dropdown includes the entire asset source alongside editable Item ID', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-item-picker-'));
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
const items = path.join(root,'assets','minecraft','items');
fs.mkdirSync(items,{recursive:true});
for(let index=0;index<105;index++) fs.writeFileSync(path.join(items,`item_${String(index).padStart(3,'0')}.json`),'{}');
h.context.Animation.selected = { name:'test.p1' };
h.context.localStorage = { getItem() { return root; } };
let configuration;
h.context.Dialog = class {
constructor(options){ configuration=options; }
};
h.createPropDialog();
const options = configuration.form.item_choice.options();
assert.equal(Object.keys(options).length,106);
assert.equal(options['minecraft:item_104'],'minecraft:item_104');
assert.equal(configuration.form.item.value,'minecraft:carrot');
});
test('imported definitions create default and stage item previews without losing hidden overrides', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-imported-props-'));
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
for (const item of ['carrot','stick']) {
const write = (relative,data) => {
const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true});
fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data));
};
write(`assets/minecraft/items/${item}.json`,{model:{type:'minecraft:model',model:`minecraft:item/${item}`}});
write(`assets/minecraft/models/item/${item}.json`,{parent:'minecraft:builtin/generated',textures:{layer0:`minecraft:item/${item}`}});
write(`assets/minecraft/textures/item/${item}.png`,'png bytes');
}
const definition={actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[
{stage:1},{stage:2,props:{player:{prop_right:'minecraft:stick'}}},{stage:3,props:{player:{prop_right:null}}}]};
const oldKey=h.propEntryKey('other','player','left',0);
const result=h.importPropPreviews('example',definition,[root],{[oldKey]:{animation:'other'}});
assert.equal(result.warnings.length,0);
assert.equal(result.entries[oldKey].animation,'other');
const get=(stage)=>result.entries[h.propEntryKey('example','player','right',stage)];
assert.equal(get(0).item,'minecraft:carrot');
assert.equal(get(0).asset.parts[0].generated,true);
assert.equal(get(1),undefined);
assert.equal(get(2).item,'minecraft:stick');
assert.equal(get(3).mode,'hidden');
const missing=h.importPropPreviews('example',{actors:[{label:'player',prop_left:'minecraft:missing'}],stages:[{stage:1}]},[root],result.entries);
assert.match(missing.warnings[0],/minecraft:missing/);
assert.equal(missing.entries[h.propEntryKey('example','player','left',0)],undefined);
assert.equal(missing.entries[oldKey].animation,'other');
});
module.exports = { harness };
+166
View File
@@ -0,0 +1,166 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function harness() {
// These tests exercise Z-axis rotations; Blockbench supplies full Three.js at runtime.
class Quaternion {
constructor() { this.angle = 0; }
setFromEuler(euler) { this.angle = euler.z; return this; }
multiply(other) { this.angle += other.angle; return this; }
invert() { this.angle = -this.angle; return this; }
toArray() { return [0, 0, Math.sin(this.angle / 2), Math.cos(this.angle / 2)]; }
fromArray(values) { this.angle = 2 * Math.atan2(values[2], values[3]); return this; }
}
class Euler {
constructor(x = 0, y = 0, z = 0) { Object.assign(this, { x, y, z }); }
setFromQuaternion(q) { this.x = this.y = 0; this.z = q.angle; return this; }
}
class Vector3 {
constructor(...values) { this.values = values; }
sub(other) { this.values = this.values.map((v, i) => v - other.values[i]); return this; }
add(other) { this.values = this.values.map((v, i) => v + other.values[i]); return this; }
applyQuaternion(q) {
const [x, y, z] = this.values, c = Math.cos(q.angle), s = Math.sin(q.angle);
this.values = [x * c - y * s, x * s + y * c, z]; return this;
}
toArray() { return this.values; }
}
class Group {
constructor(name, parent) { Object.assign(this, { name, origin: [1, 2, 3], rotation: [0, 0, 0], children: [], parent }); parent?.children.push(this); }
}
const h = { Group, Cube: class {}, THREE: { Quaternion, Euler, Vector3 },
Format: { id: 'geckolib_model', euler_order: 'ZYX' }, Plugin: { register() {} } };
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
' globalThis.api = { cemTemplateCatalog, jemPresetParts, jemTransferSuggestions, validateJemChoices, jemAdditionBones, addJemEnergyVisibility, refreshJemEnergyFields, jemTransferTransform, createJemTransferProject };\n Plugin.register(PLUGIN_ID, {');
vm.runInNewContext(source, h);
return h;
}
test('JEM preset parent paths include attachment roots and nested groups', () => {
const h = harness();
const parts = h.api.jemPresetParts({ models: [{ part: 'body', submodels: [{ id: 'fur', submodels: [{ id: 'tip' }] }] }, { part: 'head' }] });
assert.deepEqual(Array.from(parts, (part) => [part.name, part.path]), [['body', '0'], ['fur', '0/0'], ['tip', '0/0/0'], ['head', '1']]);
});
test('JEM transfer compensates different parent rotations without rotating children twice', () => {
const h = harness(), body = new h.Group('actor1_body'), extra = new h.Group('actor1_extra', body);
body.rotation = [0, 0, 90]; extra.origin = [2, 2, 3]; extra.rotation = [0, 0, 10];
const suggestions = h.api.jemTransferSuggestions([body, extra], [{ name: 'body', path: '0' }], 1);
const addition = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0' })[0];
const target = new h.Group('body'); target.origin = [10, 20, 30];
const transform = h.api.jemTransferTransform(addition, target);
transform.offset.forEach((value, axis) => assert.ok(Math.abs(value - [8, 19, 27][axis]) < 1e-8));
assert.ok(Math.abs(transform.rotation[2] - 100) < 1e-8);
assert.deepEqual(Array.from(addition.tree.rotation), [0, 0, 10]);
});
test('JEM creation rescales GeckoLib UVs but preserves per-texture UVs in other formats', () => {
for (const projectWide of [false, true]) {
const h = harness(), cubes = [];
h.Group.all = [];
h.Group.prototype.init = function() { h.Group.all.push(this); return this; };
h.Group.prototype.addTo = function(parent) { this.parent = parent; parent.children.push(this); return this; };
h.Cube = class {
constructor(data) { Object.assign(this, data); cubes.push(this); }
addTo(parent) { parent.children.push(this); return this; }
init() { return this; }
};
h.Texture = class {
constructor(data) { Object.assign(this, data, { uuid: 'texture' }); }
fromDataURL() { return this; }
add() { return this; }
};
h.Project = {};
h.Formats = { optifine_entity: { codec: { parse() { new h.Group('body').init(); } } } };
h.newProject = () => true;
h.Canvas = { updateAll() {} }; h.Blockbench = { showQuickMessage() {} };
const addition = { target: '0', parentOrigin: [0, 0, 0], parentRotation: [0, 0, 0, 1], rotationOrder: 'ZYX',
tree: { name: 'extra', origin: [0, 0, 0], rotation: [0, 0, 0], children: [{ cube: {
name: 'cube', from: [0, 0, 0], to: [1, 1, 1], box_uv: false,
faces: { north: { texture: 'old', uv: [0, 0, 16, 16] } } } }] } };
h.api.createJemTransferProject({ name: 'test', model: { models: [] } }, [addition],
[{ id: 'old', data: { uv_width: 32, uv_height: 32 }, image: 'mock' }], 'old',
{ width: 64, height: 64, projectWide }, false);
assert.deepEqual(Array.from(cubes[0].faces.north.uv), projectWide ? [0, 0, 8, 8] : [0, 0, 16, 16]);
}
});
test('suggests added subtrees once, strips actor prefixes and leaves vanilla hierarchy unchanged', () => {
const h = harness(), root = new h.Group('actor1_root'), body = new h.Group('actor1_body', root);
const d = new h.Group('actor1_d', body), tip = new h.Group('actor1_tip', d);
const head = new h.Group('actor1_head', root), extra = new h.Group('actor1_extra', root);
const suggestions = h.api.jemTransferSuggestions([root, body, d, tip, head, extra], [{ name: 'body', path: '0' }, { name: 'head', path: '1' }], 1);
assert.deepEqual(Array.from(suggestions, (item) => [item.name, item.target]), [['d', '0'], ['extra', '']]);
assert.equal(suggestions[0].root, d);
assert.equal(tip.parent, d);
assert.equal(body.name, 'actor1_body');
});
test('selected additions require parent mapping; unchecked additions are omitted', () => {
const h = harness(), body = new h.Group('actor1_body'), d = new h.Group('actor1_d', body);
const suggestions = h.api.jemTransferSuggestions([body, d], [{ name: 'body', path: '0' }], 1);
assert.throws(() => h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '' }), /Choose a target parent/);
assert.throws(() => h.api.validateJemChoices(suggestions, { include_0: false }), /at least one/);
const chosen = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0' });
assert.equal(chosen[0].tree.name, 'd');
assert.equal(chosen[0].target, '0');
assert.deepEqual(Array.from(chosen[0].parentOrigin), [1, 2, 3]);
});
test('CEM catalog integration handles its initial undefined return and dependency errors', async () => {
const h = harness();
await assert.rejects(h.api.cemTemplateCatalog(), /Install and enable/);
let calls = 0;
const catalog = { models: { wolf: {} }, entities: [{ id: 'wolf' }] };
h.window = { async loadCEMTemplateModels() { return ++calls === 1 ? undefined : catalog; } };
assert.equal(await h.api.cemTemplateCatalog(), catalog);
assert.equal(calls, 2);
});
test('energy visibility is opt-in per transferred bone including nested subbones', () => {
const h = harness(), body = new h.Group('actor1_body'), d = new h.Group('actor1_d', body);
new h.Group('actor1_tip', d);
const suggestions = h.api.jemTransferSuggestions([body, d, ...d.children], [{ name: 'body', path: '0' }], 1);
assert.deepEqual(Array.from(h.api.jemAdditionBones(suggestions[0]), (bone) => [bone.name, bone.path]), [['d', 'root'], ['tip', 'root_0']]);
const selected = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0', energy_0_root_0: true });
assert.equal(selected[0].tree.energyVisibility, false);
assert.equal(selected[0].tree.children[0].group.energyVisibility, true);
});
test('energy expressions use the final unique bone name and preserve existing preset animations', () => {
const h = harness(), root = new h.Group('body'), nested = new h.Group('addition', root);
const tip = new h.Group('d3_2', nested);
root.cem_animations = [{ 'body.rx': 'existing_expression' }];
h.api.addJemEnergyVisibility(tip);
assert.equal(root.cem_animations.length, 2);
assert.equal(root.cem_animations[0]['body.rx'], 'existing_expression');
assert.equal(root.cem_animations[1]['d3_2.visible'], 'nbt(NeedsOfNatureEnergy,range:70-200)');
assert.equal(tip.cem_animations, undefined);
});
test('parent energy visibility disables descendants and omits redundant child expressions without clearing their choices', () => {
const h = harness(), body = new h.Group('actor1_body'), d = new h.Group('actor1_d', body);
const tip = new h.Group('actor1_tip', d), end = new h.Group('actor1_end', tip);
const suggestions = h.api.jemTransferSuggestions([body, d, tip, end], [{ name: 'body', path: '0' }], 1);
const fields = Object.fromEntries(['root', 'root_0', 'root_0_0'].map((path) => {
const input = {}, bar = { style: {}, querySelectorAll() { return [input]; } };
return [`energy_0_${path}`, { bar, input }];
}));
const dialog = { form: { form_data: fields } };
const result = { include_0: true, parent_0: '0', energy_0_root: true, energy_0_root_0: true, energy_0_root_0_0: true };
h.api.refreshJemEnergyFields(dialog, suggestions, result);
assert.equal(fields.energy_0_root.input.disabled, false);
assert.equal(fields.energy_0_root_0.input.disabled, true);
assert.equal(fields.energy_0_root_0_0.input.disabled, true);
const tree = h.api.validateJemChoices(suggestions, result)[0].tree;
assert.equal(tree.energyVisibility, true);
assert.equal(tree.children[0].group.energyVisibility, false);
result.energy_0_root = false;
h.api.refreshJemEnergyFields(dialog, suggestions, result);
assert.equal(fields.energy_0_root_0.input.disabled, false);
assert.equal(fields.energy_0_root_0_0.input.disabled, true);
assert.equal(h.api.validateJemChoices(suggestions, result)[0].tree.children[0].group.energyVisibility, true);
});
+59
View File
@@ -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] }]);
});
});
+134
View File
@@ -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);
});
+325
View File
@@ -0,0 +1,325 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const test = require('node:test');
function harness() {
const storage = new Map(), blobs = [], revoked = [], undo = [];
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8')
.replace(' Plugin.register(PLUGIN_ID, {', `
globalThis.api = { loadSoundLibrary, resolveSoundAudio, mapAnimationSounds,
applyLibrarySound, compileConjoinedStage, showSoundLibrary, SOUND_SOURCE_KEY, SOUND_PREVIEW_PROPERTY,
installFiguraSoundCompat, beginFiguraSoundPlayback, removeFiguraSoundCompat, isBrokenFiguraSoundWrapper };
Plugin.register(PLUGIN_ID, {`);
class EffectAnimator {
constructor(animation) { this.animation = animation; this.sound = []; }
createKeyframe(data, time) {
const frame = { channel: 'sound', time, animator: this, select() {},
extend(data) { this.data_points = data.data_points.map((point) => ({ ...point, getUndoCopy() { return { ...this }; } })); } };
frame.extend(data); this.sound.push(frame); return frame;
}
select() {}
}
const context = {
Plugin: { register() {} }, Buffer, requireNativeModule: require, Blob,
URL: { createObjectURL(blob) { blobs.push(blob); return `blob:test-${blobs.length}`; }, revokeObjectURL(url) { revoked.push(url); } },
localStorage: { getItem(key) { return storage.get(key); }, setItem(key, value) { storage.set(key, value); } },
Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] },
Undo: { initEdit(aspects) { undo.push(aspects.keyframes.slice()); }, finishEdit() {} },
Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} },
Format: { id: 'geckolib_model' }, settings: { volume: { value: 80 } }, console,
played: [], coreCalls: [],
Audio: class {
constructor(file) { this.file = file; }
play() { context.played.push(this); return Promise.resolve(); }
pause() { this.paused = true; }
},
};
vm.runInNewContext(source, context);
return { ...context.api, context, storage, blobs, revoked, undo };
}
function brokenFigura(h) {
vm.runInNewContext(`(() => {
const format = { id: 'figura' };
const displayFrame = function (in_loop) { coreCalls.push(in_loop); return 'original-result'; };
EffectAnimator.prototype.displayFrame = function () {
if (Format === format) return;
displayFrame.call(this);
};
})()`, h.context);
const animation = { time: 0, length: 2, animators: {}, playing: true };
const effects = new h.context.EffectAnimator(animation);
effects.muted = { sound: false };
effects.sound = [{ uuid: 'cue', time: 0.5, data_points: [{ file: 'manual.ogg' }] }];
animation.animators.effects = effects;
h.context.Animation.all = [animation];
Object.assign(h.context.Timeline, { playing: true, playback_speed: 100, custom_range: [0, 0], playing_sounds: [] });
return { animation, effects, original: h.context.EffectAnimator.prototype.displayFrame };
}
function assets(t, json, files = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-sounds-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const write = (name, content) => { const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); };
write('assets/needsofnature/sounds.json', JSON.stringify(json));
for (const [name, content] of Object.entries(files)) write(`assets/needsofnature/sounds/${name}.ogg`, content);
return root;
}
test('sound library reads the real NoN sound registry, lazily maps semantic cues and caches audio', () => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources'));
const library = h.loadSoundLibrary();
assert(library.events.has('needsofnature:spray'));
assert(library.events.size > 60);
assert.equal(h.blobs.length, 0);
const audio = h.resolveSoundAudio('reactiveimpact');
assert.equal(audio, h.resolveSoundAudio('needsofnature:impactdry01'));
assert.equal(h.blobs.length, 1);
assert.equal(h.resolveSoundAudio('reactiveimpact_silent'), null);
assert.equal(h.resolveSoundAudio('not_a_sound'), null);
h.loadSoundLibrary();
assert(h.revoked.includes(audio));
});
test('event references and random pools resolve safely; cyclic or missing events stay unresolved', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, {
one: { sounds: ['needsofnature:one'] },
nested: { sounds: [{ type: 'event', name: 'needsofnature:one' }] },
loop: { sounds: [{ type: 'event', name: 'needsofnature:loop' }] },
unsafe: { sounds: ['needsofnature:../outside'] },
}, { one: 'OggS test' }));
h.loadSoundLibrary();
assert.equal(h.resolveSoundAudio('nested'), h.resolveSoundAudio('one'));
assert.equal(h.resolveSoundAudio('random(needsofnature:missing, needsofnature:one)'), h.resolveSoundAudio('one'));
assert.equal(h.resolveSoundAudio('loop'), null);
assert.equal(h.resolveSoundAudio('unsafe'), null);
});
test('pack sound priority honors replace without overriding higher-priority entries', (t) => {
const h = harness();
const high = assets(t, { one: { replace: true, sounds: ['needsofnature:high'] } }, { high: 'high' });
const low = assets(t, { one: { sounds: ['needsofnature:low'] } }, { low: 'low' });
h.storage.set('animationdirector_pack_folders', JSON.stringify([high]));
h.storage.set(h.SOUND_SOURCE_KEY, low);
const library = h.loadSoundLibrary();
assert.deepEqual(Array.from(library.events.get('needsofnature:one').entries), ['needsofnature:high']);
assert(h.resolveSoundAudio('one'));
});
test('mapping retains cue IDs and manual previews, updates managed files and handles missing/silent cues', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
h.loadSoundLibrary();
const points = [{ effect: 'reactiveimpact' }, { effect: 'reactiveimpact', file: 'custom.ogg' },
{ effect: 'unknown' }, { effect: 'reactiveimpact_silent' }];
const animation = { animators: { effects: { sound: [{ data_points: points }] } } };
h.context.Animation.all = [animation];
h.mapAnimationSounds([animation]);
const original = points[0].file;
assert(original.startsWith('blob:'));
assert.equal(points[0].effect, 'reactiveimpact');
assert.equal(points[1].file, 'custom.ogg');
assert.equal(points[2].file, '');
assert.equal(points[3].file, '');
h.loadSoundLibrary();
h.mapAnimationSounds([animation]);
assert.notEqual(points[0].file, original);
points[0].effect = 'reactiveimpact_silent';
h.mapAnimationSounds([animation]);
assert.equal(points[0].file, '');
});
test('reactive impact uses saved peak-stage metadata separately for each animation stage', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
const library = h.loadSoundLibrary();
h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: {
stages: [{ stage: 1 }, { stage: 2, non_peak: true }],
} }) };
const normal = { name: 'test.p1', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' }] }] } } };
const peaked = { name: 'test.p2', animators: { effects: { sound: [{ data_points: [{ effect: 'reactiveimpact' },
{ effect: 'reactiveimpact', file: 'manual.ogg' }, { effect: 'reactiveimpact_silent' }] }] } } };
h.mapAnimationSounds([normal, peaked]);
assert.equal(normal.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01'));
assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('shot_in01'));
assert.equal(peaked.animators.effects.sound[0].data_points[1].file, 'manual.ogg');
assert.equal(peaked.animators.effects.sound[0].data_points[2].file, '');
h.context.Animation.selected = peaked;
h.showSoundLibrary();
h.context.dialog.component.methods.preview.call({ selected: 'reactiveimpact', error: '' });
assert.equal(h.context.played[0].file, h.resolveSoundAudio('shot_in01'));
h.context.Project.animationdirector_animdef_definitions = JSON.stringify({ test: { stages: [{ stage: 2, non_peak: false }] } });
h.mapAnimationSounds([peaked]);
assert.equal(peaked.animators.effects.sound[0].data_points[0].file, h.resolveSoundAudio('impactdry01'));
assert.equal(h.resolveSoundAudio('random(needsofnature:reactiveimpact)', library, new Set(), normal), h.resolveSoundAudio('impactdry01'));
});
test('missing stage metadata defaults to a normal impact; reused peak stages do not change the source stage', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { impactdry01: { sounds: ['needsofnature:dry'] },
shot_in01: { sounds: ['needsofnature:peak'] } }, { dry: 'dry', peak: 'peak' }));
const library = h.loadSoundLibrary();
h.context.Project = { animationdirector_animdef_definitions: JSON.stringify({ test: {
stages: [{ stage: 1 }, { stage: 2, use_stage: 1, non_peak: true }],
} }) };
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p1' }), h.resolveSoundAudio('impactdry01'));
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'test.p2' }), h.resolveSoundAudio('shot_in01'));
assert.equal(h.resolveSoundAudio('reactiveimpact', library, new Set(), { name: 'other.p3' }), h.resolveSoundAudio('impactdry01'));
});
test('insertion is undoable and preserves existing cues at the same time; Apply edits selection', (t) => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, assets(t, { one: { sounds: ['needsofnature:one'] } }, { one: 'one' }));
h.loadSoundLibrary();
h.applyLibrarySound('one', true);
const animation = h.context.Animation.selected, effects = animation.animators.effects;
assert.equal(effects.sound.length, 1);
assert.equal(effects.sound[0].time, 2);
assert(effects.sound[0].data_points[0].file.startsWith('blob:'));
h.applyLibrarySound('birth', true);
assert.equal(effects.sound.length, 1);
assert.equal(effects.sound[0].data_points.length, 2);
assert.equal(effects.sound[0].data_points[0].effect, 'one');
h.context.Timeline.selected = [effects.sound[0]];
h.applyLibrarySound('reactiveimpact_silent');
assert.equal(effects.sound[0].data_points[0].effect, 'reactiveimpact_silent');
assert.equal(effects.sound[0].data_points[0].file, '');
assert.equal(h.undo.length, 3);
});
test('editor preview fields never appear in exported sound cues', () => {
const h = harness();
const clip = { sound_effects: { '0.0': [{ effect: 'reactiveimpact', file: 'blob:test',
[h.SOUND_PREVIEW_PROPERTY]: 'blob:test', volume: 0.5 }, { effect: 'custom:sound' }] } };
const result = h.compileConjoinedStage({ name: 'sample.p1', animation: { compileBedrockAnimation() { return clip; } } });
assert.equal(result.clip.sound_effects['0.0'][0].effect, 'reactiveimpact');
assert.equal(result.clip.sound_effects['0.0'][0].volume, 0.5);
assert.equal(result.clip.sound_effects['0.0'][0].file, undefined);
assert.equal(clip.sound_effects['0.0'][0].file, 'blob:test');
});
test('library UI auditions on a single click without reactive preview controls', () => {
const h = harness();
h.showSoundLibrary();
const ui = h.context.dialog.component;
assert.match(ui.template, /Search sound IDs/);
assert.match(ui.template, /Insert at Playhead/);
assert.doesNotMatch(ui.template, /Reactive preview|@dblclick/);
assert.match(ui.template, /@click="selected=row.id;preview\(\)"/);
assert.equal(typeof ui.methods.preview, 'function');
assert.equal(typeof ui.methods.reload, 'function');
});
test('sound selection hides blacklisted events without disabling existing cue previews', () => {
const h = harness();
h.storage.set(h.SOUND_SOURCE_KEY, path.resolve(__dirname, '../../NON/src/main/resources'));
h.showSoundLibrary();
const ui = h.context.dialog.component;
const state = ui.data();
assert(state.ids.includes('needsofnature:a'));
assert(state.ids.includes('needsofnature:funkytown'));
for (const query of ['', 'needsofnature:a', 'funkytown']) {
const rows = ui.computed.rows.call({ ...state, query });
assert(!rows.some((row) => row.id === 'needsofnature:a' || row.id === 'needsofnature:funkytown'));
}
assert(h.resolveSoundAudio('needsofnature:a'));
assert(h.resolveSoundAudio('needsofnature:funkytown'));
});
test('Figura workaround detects only the known faulty wrapper and restores it on unload', () => {
const h = harness(), f = brokenFigura(h);
assert(h.isBrokenFiguraSoundWrapper(f.original));
h.installFiguraSoundCompat();
const patched = h.context.EffectAnimator.prototype.displayFrame;
assert.notEqual(patched, f.original);
h.installFiguraSoundCompat();
assert.equal(h.context.EffectAnimator.prototype.displayFrame, patched);
f.animation.time = 0.5;
f.effects.displayFrame(true);
h.removeFiguraSoundCompat();
assert.equal(h.context.EffectAnimator.prototype.displayFrame, f.original);
for (const corrected of [function (in_loop) {}, function (...args) { return args; }]) {
h.context.EffectAnimator.prototype.displayFrame = corrected;
h.installFiguraSoundCompat();
assert.equal(h.context.EffectAnimator.prototype.displayFrame, corrected);
}
});
test('Figura sound workaround triggers crossed keyframes even beyond the native 33ms window', () => {
const h = harness(), f = brokenFigura(h);
h.beginFiguraSoundPlayback();
f.animation.time = 0.45; f.effects.displayFrame(true);
f.animation.time = 0.6; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 1);
assert.equal(h.context.played[0].file, 'manual.ogg');
assert.equal(h.context.played[0].volume, 0.8);
assert.equal(h.context.coreCalls.length, 2);
assert.equal(h.context.coreCalls[0], undefined);
f.animation.time = 0.7; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 1);
});
test('Figura workaround plays start cues and restarts long sounds on subsequent loops', () => {
const h = harness(), f = brokenFigura(h);
f.effects.sound[0].time = 0;
h.beginFiguraSoundPlayback();
f.animation.time = 0.04; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 1);
f.animation.time = 1.9; f.effects.displayFrame(true);
f.animation.time = 0; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 2);
assert(h.context.played[0].paused);
assert.equal(h.context.Timeline.playing_sounds.length, 1);
h.removeFiguraSoundCompat();
assert(h.context.played[1].paused);
assert.equal(h.context.Timeline.playing_sounds.length, 0);
});
test('Figura workaround respects sound mute, scrubbing, other formats and resumed native audio', () => {
const h = harness(), f = brokenFigura(h);
h.beginFiguraSoundPlayback();
f.animation.time = 0.7; f.effects.displayFrame(false);
assert.equal(h.context.played.length, 0);
f.animation.time = 0; h.beginFiguraSoundPlayback();
f.effects.muted.sound = true;
f.animation.time = 0.6; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 0);
f.effects.muted.sound = false;
f.animation.time = 0; h.beginFiguraSoundPlayback();
h.context.Format.id = 'figura';
f.animation.time = 0.6; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 0);
h.context.Format.id = 'geckolib_model';
f.animation.time = 0; h.beginFiguraSoundPlayback();
h.context.Timeline.playing_sounds.push({ keyframe_id: 'cue' });
f.animation.time = 0.6; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 0);
});
test('Figura workaround honors custom loop ranges and leaves later plugin patches installed', () => {
const h = harness(), f = brokenFigura(h);
Object.assign(h.context.Timeline, { custom_range: [0.3, 1] });
f.effects.sound = [{ uuid: 'before', time: 0.1, data_points: [{ file: 'excluded.ogg' }] },
{ uuid: 'start', time: 0.3, data_points: [{ file: 'included.ogg' }] },
{ uuid: 'after', time: 1.2, data_points: [{ file: 'excluded.ogg' }] }];
f.animation.time = 0.3; h.beginFiguraSoundPlayback();
f.animation.time = 0.35; f.effects.displayFrame(true);
f.animation.time = 0.95; f.effects.displayFrame(true);
f.animation.time = 0.3; f.effects.displayFrame(true);
assert.deepEqual(h.context.played.map((media) => media.file), ['included.ogg', 'included.ogg']);
const patched = h.context.EffectAnimator.prototype.displayFrame;
const later = function (...args) { return patched.apply(this, args); };
h.context.EffectAnimator.prototype.displayFrame = later;
h.removeFiguraSoundCompat();
assert.equal(h.context.EffectAnimator.prototype.displayFrame, later);
f.animation.time = 0.95; f.effects.displayFrame(true);
f.animation.time = 0.3; f.effects.displayFrame(true);
assert.equal(h.context.played.length, 2);
});
+66
View File
@@ -0,0 +1,66 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function harness() {
const h = { Plugin: { register() {} }, Animation: { selected: null, all: [] }, Format: { id: 'geckolib_model' },
Animator: { preview() { h.rendered = h.Animation.selected; } },
Timeline: { playing: true, custom_range: [0, 0], loop() { this.playing = false; }, setTime(time) { this.time = time; }, start() { this.playing = true; this.loop(); } } };
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
' globalThis.api = { installStageAutoplay, removeStageAutoplay, nextAutoplayStage, setEnabled(value) { autoplayStagesAction = { value }; } };\n Plugin.register(PLUGIN_ID, {');
vm.runInNewContext(source, h);
h.clip = (name, loop = 'once') => ({ name, length: 2, loop, select() { h.Animation.selected = this; } });
h.api.setEnabled(true); h.api.installStageAutoplay();
return h;
}
test('autoplay advances completed non-looping stages by number within the same ID', () => {
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
h.Animation.selected = first; h.Animation.all = [h.clip('other.p2'), h.clip('example.p10'), next, first];
h.Timeline.loop();
assert.equal(h.Animation.selected, next);
assert.equal(h.Timeline.time, 0);
assert.equal(h.Timeline.playing, true);
assert.equal(h.rendered, next);
});
test('autoplay leaves looping stages, custom ranges, disabled toggle and final stage alone', () => {
for (const mode of ['loop', 'range', 'disabled', 'final']) {
const h = harness(), first = h.clip('example.p1', mode === 'loop' ? 'loop' : 'hold');
h.Animation.selected = first; h.Animation.all = mode === 'final' ? [first] : [first, h.clip('example.p2')];
if (mode === 'range') h.Timeline.custom_range = [0, 1];
if (mode === 'disabled') h.api.setEnabled(false);
h.Timeline.loop();
assert.equal(h.pending, undefined);
assert.equal(h.Animation.selected, first);
}
});
test('manual playback stops do not advance and unload restores playback', () => {
const h = harness(), first = h.clip('example.p1'), next = h.clip('example.p2');
h.Animation.selected = first; h.Animation.all = [first, next];
h.Timeline.playing = false; h.Timeline.loop();
assert.equal(h.pending, undefined);
assert.equal(h.Animation.selected, first);
assert.equal(h.Timeline.playing, false);
const wrapper = h.Timeline.loop; h.api.removeStageAutoplay();
assert.notEqual(h.Timeline.loop, wrapper);
});
test('autoplay overrides global looping for once stages and restores it even on failure', () => {
const h = harness(); h.api.removeStageAutoplay();
h.BarItems = { looped_animation_playback: { value: true } };
h.Timeline.loop = function() { if (!h.BarItems.looped_animation_playback.value) this.playing = false; };
const first = h.clip('example.p1'), next = h.clip('example.p2');
h.Animation.selected = first; h.Animation.all = [first, next];
h.api.installStageAutoplay(); h.Timeline.loop();
assert.equal(h.Animation.selected, next);
assert.equal(h.BarItems.looped_animation_playback.value, true);
h.api.removeStageAutoplay();
h.Timeline.loop = () => { throw new Error('preview failed'); };
h.api.installStageAutoplay();
assert.throws(() => h.Timeline.loop(), /preview failed/);
assert.equal(h.BarItems.looped_animation_playback.value, true);
});
+179
View File
@@ -0,0 +1,179 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function harness() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {',
' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject, applyAllActorTexturePreviews, setProjectActorTextures, setProjectBoneTextureOverrides };\n Plugin.register(PLUGIN_ID, {'
);
class Group {
static all = [];
constructor(data) { Object.assign(this, data); }
init() { Group.all.push(this); return this; }
addTo(parent) { this.parent = parent; return this; }
}
const cubes = [];
class Cube {
static all = cubes;
constructor(name, box = false) {
Object.assign(this, typeof name === 'object' ? name : { name });
this.box_uv = box;
this.faces = { north: { uv: [0, 0, 16, 16] } };
this.preview_controller = { updateUV(cube) {
if (!cube.box_uv || !cube.uv_offset || !cube.from || !cube.to) return;
const sizeX = cube.to[0] - cube.from[0], sizeY = cube.to[1] - cube.from[1];
const sizeZ = cube.to[2] - cube.from[2];
const [u, v] = cube.uv_offset;
cube.faces.north.uv = [u + sizeZ, v + sizeZ, u + sizeZ + sizeX, v + sizeZ + sizeY];
} };
cubes.push(this);
}
init() { return this; }
addTo(parent) { this.parent = parent; return this; }
setUVMode(box) { this.box_uv = box; }
}
class Texture {
static all = [];
constructor(data) { Object.assign(this, data); this.uuid = data.name; }
fromDataURL(url) { this.source = url; return this; }
add() { Texture.all.push(this); return this; }
}
const context = { Buffer, requireNativeModule: require, Group, Cube, Texture, console, Format: { id: 'geckolib_model' },
setTimeout(callback) { callback(); },
Undo: { initEdit() {}, finishEdit() {}, cancelEdit() {} },
Project: { texture_width: 64, texture_height: 64, box_uv: true },
Canvas: { updateAllUVs() {}, updateAll() {} }, Plugin: { register() {} },
localStorage: { getItem() { return null; } },
};
vm.runInNewContext(source, context);
return { ...context.uvTest, context, Cube, Texture };
}
test('growing the project UV canvas preserves other actors and converts box UVs', () => {
const h = harness(), actor1 = new h.Cube('actor1_body', false), actor2 = new h.Cube('actor2_body', true);
h.normalizeActorUvs(1, 64, 64);
h.normalizeActorUvs(2, 64, 64);
h.normalizeActorUvs(1, 128, 64);
assert.equal(h.context.Project.texture_width, 128);
assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 16, 16]);
assert.deepEqual(Array.from(actor2.faces.north.uv), [0, 0, 32, 16]);
assert.equal(actor2.box_uv, false);
h.normalizeActorUvs(1, 32, 64);
assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 64, 16]);
assert.deepEqual(Array.from(actor2.faces.north.uv), [0, 0, 32, 16]);
h.normalizeActorUvs(1, 32, 64);
assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 64, 16], 'Repeated refreshes must not rescale UVs');
});
test('non-GeckoLib projects keep UVs, canvas dimensions and box UV mode while still assigning textures', () => {
for (const format of ['optifine_entity', 'optifine_part', 'bedrock', 'free', 'java_block']) {
const h = harness(), cube = new h.Cube('actor1_body', true);
h.context.Format.id = format;
const before = JSON.stringify(cube.faces.north.uv);
const texture = new h.Texture({ name: 'custom.png', uv_width: 128, uv_height: 128 });
texture.add();
assert.equal(h.expandProjectUvs(128, 128), false);
assert.equal(h.normalizeActorUvs(1, 128, 128), false);
h.scaleCubeUvs(cube, 2, 2);
h.applyActorTexturePreview(1, 'custom.png');
assert.equal(JSON.stringify(cube.faces.north.uv), before);
assert.equal(cube.faces.north.texture, texture.uuid);
assert.equal(cube.box_uv, true);
assert.equal(h.context.Project.texture_width, 64);
assert.equal(h.context.Project.texture_height, 64);
assert.equal(h.context.Project.animationdirector_actor_uv_basis, undefined);
}
});
test('assigning a manually imported texture to an actor normalizes only that actor once', () => {
const h = harness(), selected = new h.Cube('actor1_body'), other = new h.Cube('actor2_body');
h.normalizeActorUvs(1, 64, 64); h.normalizeActorUvs(2, 64, 64);
const texture = new h.Texture({ name: 'custom.png' });
texture.img = { naturalWidth: 32, naturalHeight: 64 };
texture.add();
h.applyActorTexturePreview(1, 'custom.png');
assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]);
assert.deepEqual(Array.from(other.faces.north.uv), [0, 0, 16, 16]);
h.applyActorTexturePreview(1, 'custom.png');
assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]);
});
test('bone texture dimensions override the base without repeated UV scaling', () => {
const h = harness();
const group = new h.context.Group({ name: 'actor1_wind', uuid: 'wind' });
group.init();
const baseCube = new h.Cube('actor1_base'), windCube = new h.Cube('actor1_wind_cube');
windCube.addTo(group);
h.context.Project.texture_width = h.context.Project.texture_height = 128;
h.normalizeActorUvs(1, 128, 128);
for (const [name, size] of [['vanilla', 32], ['wind', 128]]) {
new h.Texture({ name, uv_width: size, uv_height: size }).add();
}
h.setProjectActorTextures({ 1: 'vanilla' });
h.setProjectBoneTextureOverrides({ wind: { uuid: 'wind', name: group.name, texture: 'wind' } });
h.applyAllActorTexturePreviews();
assert.deepEqual(Array.from(baseCube.faces.north.uv), [0, 0, 64, 64]);
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]);
assert.equal(windCube.faces.north.texture, 'wind');
h.applyAllActorTexturePreviews();
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 16, 16]);
h.setProjectBoneTextureOverrides({});
h.applyAllActorTexturePreviews();
assert.deepEqual(Array.from(windCube.faces.north.uv), [0, 0, 64, 64]);
});
test('importing a larger actor preserves UV mapping of the earlier model', () => {
const h = harness();
const geometry = (width) => ({ description: { texture_width: width, texture_height: width }, bones: [{ name: 'body', cubes: [
{ origin: [0, 0, 0], size: [2, 2, 2], uv: { north: { uv: [0, 0], uv_size: [16, 16] } } },
] }] });
h.importGeometryIntoCurrentProject(geometry(64), 1, false, false);
h.importGeometryIntoCurrentProject(geometry(128), 2, false, false);
assert.equal(h.context.Project.texture_width, 128);
assert.deepEqual(Array.from(h.Cube.all[0].faces.north.uv), [0, 0, 32, 32]);
assert.deepEqual(Array.from(h.Cube.all[1].faces.north.uv), [0, 0, 16, 16]);
assert.equal(h.getActorUvBasis()['1'].width, 64);
assert.equal(h.getActorUvBasis()['2'].width, 128);
});
test('box UV import resolves face coordinates before converting to a smaller texture', () => {
const h = harness();
h.importGeometryIntoCurrentProject({ description: { texture_width: 32, texture_height: 32 }, bones: [
{ name: 'body', cubes: [{ origin: [0, 0, 0], size: [2, 3, 4], uv: [8, 4] }] },
] }, 1, false, false);
const cube = h.Cube.all[0];
assert.equal(cube.box_uv, false);
assert.deepEqual(Array.from(cube.faces.north.uv), [24, 16, 28, 22]);
});
test('box UV import keeps its original coordinates when no scaling is needed', () => {
const h = harness();
h.importGeometryIntoCurrentProject({ description: { texture_width: 64, texture_height: 64 }, bones: [
{ name: 'body', cubes: [{ origin: [0, 0, 0], size: [2, 3, 4], uv: [8, 4] }] },
] }, 1, false, false);
const cube = h.Cube.all[0];
assert.equal(cube.box_uv, true);
assert.deepEqual(Array.from(cube.faces.north.uv), [12, 8, 14, 11]);
});
test('manual model import can load an explicitly referenced texture from its extracted pack', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-uv-pack-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const write = (relative, contents) => { const file = path.join(root, relative);
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, contents); return file; };
write('pack.mcmeta', '{}');
const model = write('assets/example/geckolib/models/entity/demo.geo.json', '{}');
const png = Buffer.alloc(24); Buffer.from('89504e470d0a1a0a', 'hex').copy(png); png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
write('assets/example/textures/entity/demo.png', png);
new h.Cube('actor1_body');
h.normalizeActorUvs(1, 64, 64);
h.tryLoadImportedActorTexture({ afw_texture: 'example:textures/entity/demo.png' }, {}, model, 1);
assert.equal(h.Texture.all.length, 1);
assert.equal(h.Texture.all[0].relative_path, 'assets/example/textures/entity/demo.png');
assert.equal(JSON.parse(h.context.Project.multiactor_actor_textures)['1'], h.Texture.all[0].name);
assert.equal(h.getActorUvBasis()['1'].width, 32);
});