tweaks
This commit is contained in:
@@ -12,7 +12,8 @@ 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 };
|
||||
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 = [];
|
||||
@@ -38,6 +39,7 @@ function harness(capture = false) {
|
||||
Formats: { geckolib_model: { id: 'geckolib_model' } },
|
||||
newProject(format) {
|
||||
assert.equal(format.id, 'geckolib_model');
|
||||
context.Format = format;
|
||||
context.Project = { uuid: 'new-project' };
|
||||
return true;
|
||||
},
|
||||
@@ -530,3 +532,39 @@ test('unexpected animation-load failures cancel the batch rather than keeping a
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ function harness() {
|
||||
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: { export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
|
||||
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); } },
|
||||
@@ -103,15 +103,60 @@ function importAnalysis() {
|
||||
{ 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), [
|
||||
'Automatically Import Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||
'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||
'_', 'Actor Preferences', 'Replace Actor Model',
|
||||
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
|
||||
'_', 'Create Animation Definition', 'Export Animations',
|
||||
'_', 'Animation Definition', 'Export Animations',
|
||||
]);
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
|
||||
|
||||
@@ -5,12 +5,36 @@ 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 {}, Plugin: { register() {} } };
|
||||
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 };\n 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;
|
||||
}
|
||||
@@ -21,6 +45,49 @@ test('JEM preset parent paths include attachment roots and nested groups', () =>
|
||||
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);
|
||||
|
||||
@@ -48,3 +48,19 @@ test('manual playback stops do not advance and unload restores playback', () =>
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user