From 3e26c7a65733fb652a83c8aee67adf28eec58d15 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Thu, 1 Oct 2026 01:58:07 +0200 Subject: [PATCH] added multi gender export with exclusion for optifine models --- AnimationDirector.js | 129 ++++++++++--- test/model-variants.test.js | 163 ++++++++++++++++ test/particle-library.test.js | 344 ++++++++++++++++++++++++++++++++++ 3 files changed, 610 insertions(+), 26 deletions(-) create mode 100644 test/model-variants.test.js create mode 100644 test/particle-library.test.js diff --git a/AnimationDirector.js b/AnimationDirector.js index 51eaa56..edd1872 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -27,7 +27,10 @@ const POSE_BUTTON_EVENTS = ['select_animation', 'select_project', 'unselect_project', 'load_project', 'undo', 'redo', 'finish_edit', 'add_animation', 'remove_animation']; const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews'; const MODEL_VARIANTS_PROPERTY = 'animationdirector_model_variants'; + const JEM_VARIANTS_PROPERTY = 'animationdirector_jem_variants'; let modelVariantsProjectProperty; + let jemVariantsProjectProperty; + let jemExportActionListener; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; let propAction, propProjectProperty, propDialog, propListener; const propMeshes = new Map(); @@ -2643,7 +2646,7 @@ } Project.saved = false; Canvas.updateAll(); - if (exportNow) Formats.optifine_entity.codec.export(); + if (exportNow) exportJemModel(); else Blockbench.showQuickMessage('Created JEM project with the selected added bones.', 3000); } @@ -2733,6 +2736,7 @@ async function showCreateJemModel() { try { if (!Project) throw new Error('Open a model project first.'); + if (Format.id === 'optifine_entity') return exportJemModel(); if (!Formats.optifine_entity?.codec) throw new Error('The OptiFine Entity format is unavailable. Enable CEM Template Loader and retry.'); const sourceProject = Project; const catalog = await cemTemplateCatalog(); @@ -2757,6 +2761,18 @@ } catch (error) { Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); } } + function updateJemExportAction() { + if (!createJemModelAction) return; + const exporting = typeof Format !== 'undefined' && Format?.id === 'optifine_entity'; + const name = exporting ? 'Export JEM Model' : 'Create JEM Model'; + if (createJemModelAction.name !== name) { + if (typeof createJemModelAction.setName === 'function') createJemModelAction.setName(name); + else createJemModelAction.name = name; + } + createJemModelAction.description = exporting ? 'Export JEM gender variants with separate excluded-bone selections.' + : 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.'; + } + function buildGenderModelVariant(model, hiddenBones) { const output = clone(model); for (const geometry of output['minecraft:geometry'] || []) { @@ -2767,8 +2783,48 @@ previousSize = hidden.size; for (const bone of bones) if (hidden.has(bone.parent)) hidden.add(bone.name); } while (hidden.size !== previousSize); - // Retain the animated skeleton and locators; only exclude rendered geometry. - for (const bone of bones) if (hidden.has(bone.name)) delete bone.cubes; + geometry.bones = bones.filter((bone) => !hidden.has(bone.name)); + } + if (output.afw_bone_textures && hiddenBones.length) { + const retained = new Set((output['minecraft:geometry'] || []).flatMap((geometry) => geometry.bones || []).map((bone) => bone.name)); + output.afw_bone_textures = Object.fromEntries(Object.entries(output.afw_bone_textures).filter(([name]) => retained.has(name))); + if (!Object.keys(output.afw_bone_textures).length) delete output.afw_bone_textures; + } + return output; + } + + function jemVariantBones(model) { + const bones = []; + function collect(node, path, parent) { + bones.push({ name: path, parent, label: node.id || node.part || `Unnamed bone (${path})`, node }); + (node.submodels || []).forEach((child, index) => collect(child, `${path}/${index}`, path)); + if (node.submodel) collect(node.submodel, `${path}/single`, path); + } + (model.models || []).forEach((node, index) => collect(node, String(index), undefined)); + return bones; + } + + function buildJemModelVariant(model, hiddenBones) { + const output = clone(model); + const excluded = new Set(hiddenBones); + const removedIds = new Set(); + function rememberIds(node) { + if (node.id) removedIds.add(node.id); + for (const child of node.submodels || []) rememberIds(child); + if (node.submodel) rememberIds(node.submodel); + } + function filter(node, path) { + if (excluded.has(path)) { rememberIds(node); return false; } + if (node.submodels) node.submodels = node.submodels.filter((child, index) => filter(child, `${path}/${index}`)); + if (node.submodel && !filter(node.submodel, `${path}/single`)) delete node.submodel; + return true; + } + output.models = (output.models || []).filter((node, index) => filter(node, String(index))); + // Do not leave animation assignments targeting a removed model ID. + for (const { node } of jemVariantBones(output)) { + if (node.animations) node.animations = node.animations.map((animation) => Object.fromEntries( + Object.entries(animation).filter(([target]) => !removedIds.has(target.split('.')[0])) + )).filter((animation) => Object.keys(animation).length); } return output; } @@ -2798,7 +2854,7 @@ const field = dialog.form?.form_data?.[key]; if (!field?.bar) return; field.bar.style.opacity = ancestor ? '0.4' : ''; - field.bar.title = ancestor ? `Already hidden by ${ancestor}` : ''; + field.bar.title = ancestor ? `Already excluded by ${bones.find((entry) => entry.name === ancestor)?.label || ancestor}` : ''; for (const input of field.bar.querySelectorAll('input')) input.disabled = !!ancestor; }); } @@ -2826,44 +2882,55 @@ } } - function exportGeckoModel() { + function exportGeckoModel() { exportModelVariants(false); } + + function exportJemModel() { exportModelVariants(true); } + + function exportModelVariants(jem) { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); return; } try { - const compiled = compileGeckoModelJson(); - const afwBoneTextures = buildAfwBoneTextureMap(); - const output = addAfwBoneTextureMapToGeckoJson(compiled, afwBoneTextures); + const codec = jem ? Formats.optifine_entity?.codec : null; + if (jem && (!codec || Format.id !== 'optifine_entity')) throw new Error('Open an OptiFine Entity (JEM) project first.'); + const output = jem ? parseCompiledModelContent(codec.compile(typeof codec.getExportOptions === 'function' ? codec.getExportOptions() : undefined)) + : addAfwBoneTextureMapToGeckoJson(compileGeckoModelJson(), buildAfwBoneTextureMap()); + if (jem && !Array.isArray(output.models)) throw new Error('The JEM compiler returned no model parts.'); + const property = jem ? JEM_VARIANTS_PROPERTY : MODEL_VARIANTS_PROPERTY; + const title = jem ? 'Export JEM Models' : 'Export GeckoLib Models'; + const extension = jem ? 'jem' : 'geo.json'; + const suffix = (gender) => jem ? ({ m: '1', f: '2', mf: '3' })[gender] : `.${gender}`; const sourceProject = Project; let saved = {}; - try { saved = JSON.parse(Project[MODEL_VARIANTS_PROPERTY] || '{}'); } catch (_) { /* Use defaults for invalid saved options. */ } - const bones = [...new Map((output['minecraft:geometry'] || []) + try { saved = JSON.parse(Project[property] || '{}'); } catch (_) { /* Use defaults for invalid saved options. */ } + const bones = jem ? jemVariantBones(output) : [...new Map((output['minecraft:geometry'] || []) .flatMap((geometry) => geometry.bones || []).map((bone) => [bone.name, bone])).values()]; const form = { - name: { type: 'text', label: 'Base model filename', value: saved.name || getModelExportFileName().replace(/\.(m|f|mf)$/i, '') }, - base: { type: 'checkbox', label: 'Export unqualified model (.geo.json)', value: saved.base ?? true }, - info: { type: 'info', text: 'Export one or more variants from this model. Hidden bones include their descendants. Bones and locators remain available to animations; the project geometry is unchanged.' }, + name: { type: 'text', label: 'Base model filename', value: saved.name || (jem + ? getModelExportFileName().replace(/\.jem$/i, '').replace(/[123]$/, '') : getModelExportFileName().replace(/\.(m|f|mf)$/i, '')) }, + base: { type: 'checkbox', label: `Export unqualified model (.${extension})`, value: saved.base ?? true }, + info: { type: 'info', text: 'Export one or more variants from this model. Excluded bones and their descendants are completely omitted, including geometry and locators. The source project is unchanged.' }, }; for (const [gender, label] of Object.entries({ m: 'Male', f: 'Female', mf: 'Male + female' })) { - form[`heading_${gender}`] = { type: 'info', text: `### ${label} (.${gender}.geo.json)` }; + form[`heading_${gender}`] = { type: 'info', text: `### ${label} (${suffix(gender)}.${extension})` }; form[gender] = { type: 'checkbox', label: `Export ${label.toLowerCase()}`, value: !!saved[gender] }; const hidden = Array.isArray(saved[`hide_${gender}`]) ? saved[`hide_${gender}`] : []; - form[`hide_heading_${gender}`] = { type: 'info', text: 'Hide bones (children are automatically included):', + form[`hide_heading_${gender}`] = { type: 'info', text: 'Exclude bones (children are automatically included):', condition: (result) => !!result[gender] }; bones.forEach((bone, index) => { - form[`hide_${gender}_${index}`] = { type: 'checkbox', label: bone.name, + form[`hide_${gender}_${index}`] = { type: 'checkbox', label: bone.label || bone.name, value: hidden.includes(bone.name), condition: (result) => !!result[gender] }; }); } - const dialog = new Dialog({ id: 'animationdirector_model_variants', title: 'Export GeckoLib Models', width: 650, form, + const dialog = new Dialog({ id: 'animationdirector_model_variants', title, width: 650, form, onFormChange(result) { refreshModelVariantFields(this, bones, result); }, onConfirm(result) { try { if (Project !== sourceProject) throw new Error('Return to the source model project and retry.'); - const name = String(result.name || '').trim().replace(/\.geo\.json$/i, ''); + const name = String(result.name || '').trim().replace(jem ? /\.jem$/i : /\.geo\.json$/i, ''); if (!name || /[<>:"/\\|?*]/.test(name)) throw new Error('Choose a valid base model filename without a folder path.'); const genders = ['m', 'f', 'mf'].filter((gender) => result[gender]); if (!result.base && !genders.length) throw new Error('Select at least one model to export.'); @@ -2874,22 +2941,22 @@ const selected = new Set(bones.filter((bone, index) => result[`hide_${gender}_${index}`]).map((bone) => bone.name)); settings[`hide_${gender}`] = [...selected].filter((name) => !hiddenModelAncestor(name, parents, selected)); } - Project[MODEL_VARIANTS_PROPERTY] = JSON.stringify(settings); + Project[property] = JSON.stringify(settings); Project.saved = false; const files = [...(result.base ? [{ name, model: output }] : []), ...genders.map((gender) => ({ - name: `${name}.${gender}`, model: buildGenderModelVariant(output, settings[`hide_${gender}`]), + name: `${name}${suffix(gender)}`, model: (jem ? buildJemModelVariant : buildGenderModelVariant)(output, settings[`hide_${gender}`]), }))]; // Open sequential save dialogs rather than competing simultaneous dialogs. const exportNext = (index) => { if (index >= files.length) return; const file = files[index]; - Blockbench.export({ type: 'GeckoLib Model JSON', extensions: ['json', 'geo.json'], - name: `${file.name}.geo`, content: stringifyCompactModelJson(file.model, 2), savetype: 'text' }, + Blockbench.export({ type: jem ? 'JEM Model' : 'GeckoLib Model JSON', extensions: jem ? ['jem'] : ['json', 'geo.json'], + name: jem ? file.name : `${file.name}.geo`, content: stringifyCompactModelJson(file.model, 2), savetype: 'text' }, () => exportNext(index + 1)); }; exportNext(0); } catch (error) { - Blockbench.showMessageBox({ title: 'Export GeckoLib Models', message: error.message }); + Blockbench.showMessageBox({ title, message: error.message }); return false; } }, @@ -2900,7 +2967,7 @@ } catch (e) { console.error(e); Blockbench.showMessageBox({ - title: 'Export GeckoLib Model', + title: jem ? 'Export JEM Models' : 'Export GeckoLib Model', message: `Export failed: ${e?.message || e}`, buttons: ['OK'], }); @@ -7059,19 +7126,25 @@ Features without a reference need no additional addon. Whole-animation import an exportGeckoModelAction = new Action('animationdirector_export_gecko_model', { name: 'Export GeckoLib Model', - description: 'Export GeckoLib geometry, including m/f/mf variants with separate hidden-bone selections.', + description: 'Export GeckoLib geometry, including m/f/mf variants with separate excluded-bone selections.', icon: 'file_upload', click: exportGeckoModel, }); if (typeof ModelProject !== 'undefined' && !ModelProject.properties[MODEL_VARIANTS_PROPERTY]) { modelVariantsProjectProperty = new Property(ModelProject, 'string', MODEL_VARIANTS_PROPERTY, { default: '{}' }); } + if (typeof ModelProject !== 'undefined' && !ModelProject.properties[JEM_VARIANTS_PROPERTY]) { + jemVariantsProjectProperty = new Property(ModelProject, 'string', JEM_VARIANTS_PROPERTY, { default: '{}' }); + } createJemModelAction = new Action('animationdirector_create_jem_model', { - condition: () => !Blockbench.isWeb, + condition: () => { updateJemExportAction(); return !Blockbench.isWeb; }, name: 'Create JEM Model', icon: 'icon-format_optifine', description: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.', click: showCreateJemModel, }); + jemExportActionListener = updateJemExportAction; + Blockbench.on('select_project unselect_project load_project new_project', jemExportActionListener); + updateJemExportAction(); exportConjoinedAction = new Action('export_conjoined_actor_animations', { name: 'Export Animations', @@ -7185,6 +7258,9 @@ Features without a reference need no additional addon. Whole-animation import an boneTextureOverridesAction.delete(); exportGeckoModelAction.delete(); createJemModelAction?.delete(); + for (const event of ['select_project', 'unselect_project', 'load_project', 'new_project']) { + if (jemExportActionListener) Blockbench.removeListener(event, jemExportActionListener); + } replaceActorAction.delete(); exportConjoinedAction.delete(); createAnimationDefinitionAction.delete(); @@ -7218,6 +7294,7 @@ Features without a reference need no additional addon. Whole-animation import an if (animdefAnimatorsProjectProperty?.delete) animdefAnimatorsProjectProperty.delete(); if (animdefDefinitionsProjectProperty?.delete) animdefDefinitionsProjectProperty.delete(); modelVariantsProjectProperty?.delete(); + jemVariantsProjectProperty?.delete(); if (blockRequirementsProjectProperty?.delete) blockRequirementsProjectProperty.delete(); }, }); diff --git a/test/model-variants.test.js b/test/model-variants.test.js new file mode 100644 index 0000000..5d483c1 --- /dev/null +++ b/test/model-variants.test.js @@ -0,0 +1,163 @@ +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 variantBuilder() { + const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( + ' Plugin.register(PLUGIN_ID, {', + ' globalThis.variantTest = { buildGenderModelVariant, refreshModelVariantFields, createModelVariantBoneSections, buildJemModelVariant };\n Plugin.register(PLUGIN_ID, {' + ); + const context = { Plugin: { register() {} }, console }; + vm.runInNewContext(source, context); + return Object.assign(context.variantTest.buildGenderModelVariant, { refresh: context.variantTest.refreshModelVariantFields, + sections: context.variantTest.createModelVariantBoneSections, jem: context.variantTest.buildJemModelVariant }); +} + +test('gender variants omit selected bones, descendants, locators and texture overrides without changing source', () => { + const build = variantBuilder(); + const source = { format_version: '1.12.0', 'minecraft:geometry': [{ + description: { identifier: 'geometry.example', texture_width: 64 }, + bones: [ + { name: 'detail', parent: 'd', cubes: [{ origin: [0, 0, 0] }] }, + { name: 'body', cubes: [{}] }, + { name: 'd', parent: 'body', cubes: [{}], locators: { D: [0, 1, 0] } }, + { name: 'head', parent: 'body', cubes: [{}] }, + ], + }], afw_bone_textures: { d: 'example:textures/custom.png' } }; + const before = JSON.stringify(source); + const female = JSON.parse(JSON.stringify(build(source, ['d']))); + assert.deepEqual(female['minecraft:geometry'][0].bones.map((bone) => bone.name), ['body', 'head']); + assert.deepEqual(female['minecraft:geometry'][0].bones[0].cubes, [{}]); + assert.equal(female.afw_bone_textures, undefined); + assert.equal(JSON.stringify(build(source, [])), before); + assert.equal(JSON.stringify(source), before); +}); + +test('variant subtree propagation handles nested children and unknown saved selections', () => { + const build = variantBuilder(); + const source = { 'minecraft:geometry': [{ bones: [ + { name: 'grandchild', parent: 'child', cubes: [{}] }, + { name: 'child', parent: 'root', cubes: [{}] }, + { name: 'root', cubes: [{}] }, + ] }] }; + const hidden = build(source, ['root', 'deleted']); + assert.equal(hidden['minecraft:geometry'][0].bones.length, 0); +}); + +test('selected hidden parents disable and clear descendant selections separately for each gender', () => { + const build = variantBuilder(); + const bones = [{ name: 'body' }, { name: 'child', parent: 'body' }, { name: 'tip', parent: 'child' }]; + const fields = {}, updates = []; + for (const gender of ['m', 'f', 'mf']) for (let index = 0; index < bones.length; index++) { + const input = { disabled: false }; + fields[`hide_${gender}_${index}`] = { input, bar: { style: {}, querySelectorAll: () => [input] } }; + } + const dialog = { form: { form_data: fields, setValues: (values) => updates.push(values) } }; + const values = { hide_m_0: true, hide_m_1: true, hide_m_2: true, hide_f_1: true, hide_mf_0: true, hide_mf_2: true }; + build.refresh(dialog, bones, values); + assert.equal(values.hide_m_1, false); + assert.equal(values.hide_m_2, false); + assert.equal(fields.hide_m_1.input.disabled, true); + assert.equal(fields.hide_m_2.input.disabled, true); + assert.equal(fields.hide_f_1.input.disabled, false); + assert.equal(fields.hide_f_2.input.disabled, true); + assert.equal(values.hide_mf_2, false); + assert.equal(fields.hide_mf_2.input.disabled, true); + assert.match(fields.hide_m_2.bar.title, /Already excluded/); + values.hide_m_0 = false; + build.refresh(dialog, bones, values); + assert.equal(fields.hide_m_2.input.disabled, false); +}); + +test('each gender bone list gets an independent bounded scroll container', () => { + const build = variantBuilder(); + const nodes = []; + const document = { createElement() { + return { style: {}, children: [], appendChild(child) { + nodes.splice(nodes.indexOf(child), 1); + this.children.push(child); + child.parentNode = this; + } }; + } }; + const parent = { insertBefore(node, reference) { nodes.splice(nodes.indexOf(reference), 0, node); } }; + const fields = {}; + for (const gender of ['m', 'f', 'mf']) for (let index = 0; index < 2; index++) { + const bar = { parentNode: parent, ownerDocument: document, style: {}, querySelectorAll: () => [] }; + fields[`hide_${gender}_${index}`] = { bar }; + nodes.push(bar); + } + const dialog = { form: { form_data: fields } }; + const bones = [{ name: 'body' }, { name: 'head', parent: 'body' }]; + build.sections(dialog, bones); + assert.equal(nodes.length, 3); + for (const gender of ['m', 'f', 'mf']) { + const section = dialog.modelVariantBoneSections[gender]; + assert.equal(section.children.length, 2); + assert.equal(section.style.maxHeight, '160px'); + assert.equal(section.style.overflowY, 'auto'); + assert.equal(section.children[0], fields[`hide_${gender}_0`].bar); + } + build.refresh(dialog, bones, { m: true, f: false, mf: true }); + assert.equal(dialog.modelVariantBoneSections.f.style.display, 'none'); + assert.equal(dialog.modelVariantBoneSections.mf.style.display, ''); +}); + +test('JEM exclusion removes whole subtrees without changing remaining roots or source', () => { + const model = { texture: 'custom.png', textureSize: [64, 64], models: [{ part: 'body', id: 'body', + boxes: [{}], submodels: [{ id: 'd', boxes: [{}], sprites: [{}], + animations: [{ 'd.visible': 'nbt(NeedsOfNatureEnergy,range:70-200)' }], + submodel: { id: 'tip', boxes: [{}] } }, { id: 'detail', boxes: [{}] }] }] }; + const original = JSON.stringify(model); + const output = JSON.parse(JSON.stringify(variantBuilder().jem(model, ['0/0']))); + assert.deepEqual(output.models[0].boxes, [{}]); + assert.equal(output.models[0].submodels.length, 1); + assert.equal(output.models[0].submodels[0].id, 'detail'); + assert.deepEqual(output.models[0].submodels[0].boxes, [{}]); + assert.equal(output.texture, 'custom.png'); + assert.equal(JSON.stringify(model), original); +}); + +test('JEM export opens the shared variant screen directly and writes numbered gender files', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( + ' Plugin.register(PLUGIN_ID, {', + ' globalThis.variantTest = { showCreateJemModel };\n Plugin.register(PLUGIN_ID, {' + ); + const model = { models: [{ part: 'body', id: 'body', boxes: [{}], submodels: [{ id: 'd', boxes: [{}] }] }] }; + let dialog; + const files = [], errors = []; + const codec = { compile: () => model, fileName: () => 'wolf' }; + const context = { Plugin: { register() {} }, Project: { name: 'wolf' }, + Format: { id: 'optifine_entity', codec }, Formats: { optifine_entity: { codec } }, console, + Dialog: class { + constructor(options) { dialog = options; this.form = { form_data: {} }; } + show() {} + getFormResult() { return Object.fromEntries(Object.entries(dialog.form).map(([key, field]) => [key, field.value])); } + }, + Blockbench: { export: (file, callback) => { files.push(file); callback(); }, + showMessageBox: (error) => errors.push(error.message) }, + }; + vm.runInNewContext(source, context); + context.variantTest.showCreateJemModel(); + assert.equal(dialog.title, 'Export JEM Models'); + assert.equal(dialog.form.hide_mf_1.label, 'd'); + const result = Object.fromEntries(Object.entries(dialog.form).map(([key, field]) => [key, field.value])); + Object.assign(result, { base: false, m: true, f: true, mf: true, hide_f_1: true }); + dialog.onConfirm(result); + assert.deepEqual(errors, []); + assert.deepEqual(files.map((file) => file.name), ['wolf1', 'wolf2', 'wolf3']); + assert.deepEqual(Array.from(files[0].extensions), ['jem']); + assert.equal(JSON.parse(files[1].content).models[0].submodels.length, 0); + assert.deepEqual(JSON.parse(files[2].content), model); + assert.equal(JSON.parse(context.Project.animationdirector_jem_variants).hide_f[0], '0/0'); +}); + +test('JEM exclusion supports singular submodels and clears animation targets for removed IDs', () => { + const model = { models: [{ id: 'body', submodel: { id: 'd', boxes: [{}] }, + animations: [{ 'd.visible': 'true', 'body.visible': 'true' }] }, { id: 'head' }] }; + const output = JSON.parse(JSON.stringify(variantBuilder().jem(model, ['0/single', '1']))); + assert.equal(output.models.length, 1); + assert.equal(output.models[0].submodel, undefined); + assert.deepEqual(output.models[0].animations, [{ 'body.visible': 'true' }]); +}); diff --git a/test/particle-library.test.js b/test/particle-library.test.js new file mode 100644 index 0000000..9eb6fc0 --- /dev/null +++ b/test/particle-library.test.js @@ -0,0 +1,344 @@ +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(), edits = [], elements = new Map(); + const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8') + .replace(' Plugin.register(PLUGIN_ID, {', ` + globalThis.api = { loadParticleLibrary, particleJsonPaths, particleThumbnailRows, availableParticleLocators, applyLibraryParticle, + showParticleLibrary, refreshSoundLibraryPanel, NON_PARTICLE_IDS, PARTICLE_SOURCE_KEY, + buildParticlePreview, mapAnimationParticles, clearParticlePreviews, setParticlePreviewsEnabled, + PARTICLE_PREVIEW_PROPERTY, PARTICLE_PREVIEW_PREFIX, cleanEffectPreviewFields }; + Plugin.register(PLUGIN_ID, {`); + class EffectAnimator { + constructor(animation) { this.animation = animation; this.particle = []; } + select() {} + createKeyframe(data, time, channel) { + const frame = { channel, time, animator: this, select() {}, + extend(data) { this.data_points = data.data_points.map((point) => ({ ...point, getUndoCopy() { return { ...this }; } })); } }; + frame.extend(data); this[channel].push(frame); return frame; + } + } + const context = { + Plugin: { register() {} }, Buffer, requireNativeModule: require, console, + Format: { id: 'geckolib_model' }, + localStorage: { getItem(key) { return storage.get(key); }, setItem(key, value) { storage.set(key, value); } }, + Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] }, Locator: { all: [] }, + Undo: { initEdit(aspects) { edits.push({ before: aspects.keyframes.slice() }); }, + finishEdit(label, aspects) { Object.assign(edits.at(-1), { label, after: aspects?.keyframes.slice() }); } }, + Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} }, + Blockbench: { isWeb: false }, + document: { + getElementById(id) { return elements.get(id); }, + createElement() { return { style: {}, addEventListener(event, callback) { this.click = callback; }, + remove() { elements.delete(this.id); } }; }, + }, + }; + elements.set('panel_keyframe', { appendChild(button) { elements.set(button.id, button); } }); + vm.runInNewContext(source, context); + return { ...context.api, context, storage, edits, elements }; +} + +test('particle library includes all registered NoN particles without an asset source', () => { + const h = harness(); + const library = h.loadParticleLibrary(); + const registry = fs.readFileSync(path.join(__dirname, '..', '..', 'NON', 'src', 'main', 'java', 'com', 'nonid', 'particle', 'NonParticles.java'), 'utf8'); + const registered = [...registry.matchAll(/NeedsOfNature\.id\("([a-z_]+)"\)/g)].map((match) => `needsofnature:${match[1]}`).sort(); + assert.deepEqual(Array.from(library.ids), registered); + assert.equal(library.warnings.length, 0); +}); + +test('particle library discovers nested IDs from asset folders and deduplicates built-ins', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particles-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const name of ['minecraft/particles/heart.json', 'addon/particles/nested/spark.json', + 'needsofnature/particles/smallheart.json', 'minecraft/textures/particle/not_a_particle.json']) { + const file = path.join(root, 'assets', name); + fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '{"textures":[]}'); + } + h.storage.set(h.PARTICLE_SOURCE_KEY, path.join(root, 'assets')); + const library = h.loadParticleLibrary(); + assert.ok(library.ids.includes('minecraft:heart')); + assert.ok(library.ids.includes('addon:nested/spark')); + assert.equal(library.ids.filter((id) => id === 'needsofnature:smallheart').length, 1); + assert.ok(library.ids.slice(0, h.NON_PARTICLE_IDS.length).every((id) => id.startsWith('needsofnature:')), + 'NoN particles appear before every other namespace'); + assert.deepEqual(Array.from(library.ids.slice(h.NON_PARTICLE_IDS.length)), ['addon:nested/spark', 'minecraft:heart'], + 'Other particles remain alphabetically sorted'); + assert.ok(!library.ids.some((id) => id.includes('not_a_particle'))); + assert.equal(library.warnings.length, 0); + const archive = { files: new Map(['assets/minecraft/particles/heart.json', + 'assets/test/particles/sub/spark.json', 'assets/test/particles/../escape.json', 'assets/test/sounds.json'].map((name) => [name, {}])) }; + const names = h.particleJsonPaths(archive, { fs, path }); + assert.ok(names.includes('assets/test/particles/sub/spark.json')); + // IDs are additionally validated before the catalog accepts archive entries. + assert.equal(names.includes('assets/test/sounds.json'), false); +}); + +test('applying a particle preserves locators and scripts while removing an unrelated preview file', () => { + const h = harness(), animation = h.context.Animation.selected; + const effects = new h.context.EffectAnimator(animation); + const frame = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor1_d', script: 'custom', file: 'old.json' }, + { effect: 'minecraft:smoke', locator: 'actor2_d' }] }, 1, 'particle'); + h.context.Timeline.selected = [frame]; + h.applyLibraryParticle('needsofnature:liquid_particle_falling'); + assert.equal(frame.data_points[0].effect, 'needsofnature:liquid_particle_falling'); + assert.equal(frame.data_points[0].locator, 'actor1_d'); + assert.equal(frame.data_points[0].script, 'custom'); + assert.equal(frame.data_points[0].file, ''); + assert.equal(frame.data_points[1].effect, 'minecraft:smoke'); + assert.equal(h.edits[0].before[0], frame); + assert.equal(h.edits[0].label, 'Apply particle from library'); +}); + +test('particle insertion uses the playhead and appends cues without overwriting existing ones', () => { + const h = harness(); + h.applyLibraryParticle('minecraft:heart', true); + const frames = h.context.Animation.selected.animators.effects.particle; + assert.equal(frames.length, 1); + assert.equal(frames[0].time, 2); + assert.equal(frames[0].channel, 'particle'); + assert.equal(h.edits[0].before.length, 0); + assert.equal(h.edits[0].after[0], frames[0]); + frames[0].data_points[0].locator = 'actor1_d'; + h.applyLibraryParticle('minecraft:smoke', true); + assert.equal(frames.length, 1); + assert.equal(frames[0].data_points.length, 2); + assert.equal(frames[0].data_points[0].locator, 'actor1_d'); + assert.equal(frames[0].data_points[1].effect, 'minecraft:smoke'); + assert.equal(frames[0].data_points[1].file, undefined, 'No particle preview is assigned'); + h.context.Animation.selected = null; + assert.throws(() => h.applyLibraryParticle('minecraft:heart'), /Select an animation/); + assert.throws(() => h.applyLibraryParticle('../escape'), /Invalid particle ID/); +}); + +test('keyframe library buttons match their channels and do not duplicate on refresh', () => { + const h = harness(), particleId = 'animationdirector_particle_library_button', soundId = 'animationdirector_sound_library_button'; + for (const channels of [[], ['particle'], ['sound'], ['rotation'], ['particle', 'sound'], ['particle', 'particle']]) { + h.context.Timeline.selected = channels.map((channel) => ({ channel })); + h.refreshSoundLibraryPanel(); + assert.equal(h.elements.has(particleId), channels.length > 0 && channels.every((channel) => channel === 'particle')); + assert.equal(h.elements.has(soundId), channels.length > 0 && channels.every((channel) => channel === 'sound')); + } + const button = h.elements.get(particleId); + h.refreshSoundLibraryPanel(); assert.equal(h.elements.get(particleId), button); + button.click(); assert.equal(h.context.dialog.title, 'NoN Particle Library'); + const component = h.context.dialog.component, data = component.data(); + data.query = 'liquid'; data.category = 'needsofnature'; + const rows = component.computed.rows.call(data); + assert.ok(rows.length > 0 && rows.every((id) => id.startsWith('needsofnature:') && id.includes('liquid'))); + assert.doesNotMatch(component.template, /@click="preview|@click="play/); + h.context.Blockbench.isWeb = true; h.refreshSoundLibraryPanel(); + assert.equal(h.elements.has(particleId), false); +}); + +test('locator selector lists unique project locators and preselects the current cue locator', () => { + const h = harness(), animation = h.context.Animation.selected; + h.context.Locator.all = [{ name: 'actor2_d' }, { name: 'actor1_d' }, { name: 'actor2_d' }]; + assert.deepEqual(Array.from(h.availableParticleLocators()), ['actor1_d', 'actor2_d']); + h.showParticleLibrary(); + let data = h.context.dialog.component.data(); + assert.equal(data.locator, 'actor1_d', 'New cues default to the first available locator'); + const effects = new h.context.EffectAnimator(animation); + const frame = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor2_d' }] }, 1, 'particle'); + h.context.Timeline.selected = [frame]; + h.showParticleLibrary(); + data = h.context.dialog.component.data(); + assert.equal(data.locator, 'actor2_d'); + data.selected = 'minecraft:smoke'; data.locator = 'actor1_d'; + h.context.dialog.component.methods.apply.call(data, false); + assert.equal(frame.data_points[0].locator, 'actor1_d'); + assert.equal(frame.data_points[0].effect, 'minecraft:smoke'); + assert.equal(data.error, ''); + h.applyLibraryParticle('minecraft:heart', true, 'actor2_d'); + assert.equal(animation.animators.effects.particle[0].data_points[0].locator, 'actor2_d'); + h.context.Locator.all = []; + assert.throws(() => h.applyLibraryParticle('minecraft:heart', false, 'actor2_d'), /no longer available/); +}); + +test('mixed locator selections keep their existing locators unless explicitly changed', () => { + const h = harness(), effects = new h.context.EffectAnimator(h.context.Animation.selected); + h.context.Locator.all = [{ name: 'actor1_d' }, { name: 'actor2_d' }]; + const first = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor1_d' }] }, 1, 'particle'); + const second = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor2_d' }] }, 2, 'particle'); + h.context.Timeline.selected = [first, second]; + h.showParticleLibrary(); + assert.equal(h.context.dialog.component.data().locator, ''); + h.applyLibraryParticle('minecraft:smoke'); + assert.equal(first.data_points[0].locator, 'actor1_d'); + assert.equal(second.data_points[0].locator, 'actor2_d'); + second.data_points[0].locator = ''; + h.showParticleLibrary(); + assert.equal(h.context.dialog.component.data().locator, '', 'A mix of assigned and unassigned cues also stays unchanged'); +}); + +test('sprite thumbnails use the first texture, support cross-source assets and cache only image data', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-sprites-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const write = (name, data) => { + const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, data); return file; + }; + const sprite = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXaAAAAAASUVORK5CYII=', 'base64'); + write('pack/assets/test/particles/spark.json', JSON.stringify({ textures: ['other:bright', 'other:second'] })); + const png = write('sprites/assets/other/textures/particle/bright.png', sprite); + write('pack/assets/test/particles/broken.json', '{ invalid json'); + write('pack/assets/test/particles/unsafe.json', JSON.stringify({ textures: ['other:../escape'] })); + write('pack/assets/test/particles/empty.json', JSON.stringify({ textures: [] })); + h.storage.set(h.PARTICLE_SOURCE_KEY, path.join(root, 'pack')); + h.storage.set('animationdirector_prop_source', path.join(root, 'sprites')); + const library = h.loadParticleLibrary(); + assert.equal(library.thumbnails.size, 0, 'Opening the catalog does not eagerly decode all sprites'); + const rows = h.particleThumbnailRows(['test:spark', 'test:broken', 'test:unsafe', 'test:empty', 'needsofnature:smallheart'], library); + assert.equal(rows[0].thumbnail, `data:image/png;base64,${sprite.toString('base64')}`); + assert.ok(rows.slice(1).every((row) => row.thumbnail === '')); + assert.equal(library.thumbnailBytes, sprite.length); + fs.unlinkSync(png); + assert.equal(h.particleThumbnailRows(['test:spark'], library)[0].thumbnail, rows[0].thumbnail); + assert.equal(library.thumbnailBytes, sprite.length, 'Repeated renders do not grow the cache'); + const refreshed = h.loadParticleLibrary(); + assert.equal(h.particleThumbnailRows(['test:spark'], refreshed)[0].thumbnail, '', 'Refresh clears cached sprites'); +}); + +test('sprite thumbnails enforce image dimensions and total cache limits', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-limits-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const particles = path.join(root, 'assets/test/particles'), textures = path.join(root, 'assets/test/textures/particle'); + fs.mkdirSync(particles, { recursive: true }); fs.mkdirSync(textures, { recursive: true }); + fs.writeFileSync(path.join(particles, 'big.json'), JSON.stringify({ textures: ['test:big'] })); + const bytes = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); + bytes.writeUInt32BE(2048, 16); bytes.writeUInt32BE(1, 20); + fs.writeFileSync(path.join(textures, 'big.png'), bytes); + h.storage.set(h.PARTICLE_SOURCE_KEY, root); + const library = h.loadParticleLibrary(); + assert.equal(h.particleThumbnailRows(['test:big'], library)[0].thumbnail, ''); + assert.equal(library.thumbnailBytes, 0); + library.thumbnails.clear(); library.thumbnailBytes = 8 * 1024 * 1024; + assert.equal(h.particleThumbnailRows(['test:big'], library)[0].thumbnail, ''); + assert.deepEqual(Array.from(h.particleThumbnailRows(['test:big'], null), (row) => row.thumbnail), ['']); +}); + +function previewHarness(t) { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-preview-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const particles = path.join(root, 'assets/needsofnature/particles'), textures = path.join(root, 'assets/needsofnature/textures/particle'); + fs.mkdirSync(particles, { recursive: true }); fs.mkdirSync(textures, { recursive: true }); + const sprite = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXaAAAAAASUVORK5CYII=', 'base64'); + for (const name of ['smallheart', 'liquid_particle', 'liquid_particle_falling', 'ripped_fabric']) { + fs.writeFileSync(path.join(particles, `${name}.json`), JSON.stringify({ textures: [`needsofnature:${name}`] })); + fs.writeFileSync(path.join(textures, `${name}.png`), sprite); + } + h.storage.set(h.PARTICLE_SOURCE_KEY, root); + h.context.Locator.all = [{ name: 'actor1_d' }]; + h.context.WinterskyScene = {}; + h.context.Wintersky = { Config: class { + constructor(scene, values) { this.scene = scene; Object.assign(this, values); this.texture = { dispose() { this.disposed = true; } }; } + setFromJSON(json) { this.json = json; return this; } + } }; + h.context.Animator = { particle_effects: {}, preview() {} }; + const animation = h.context.Animation.selected; + h.context.Animation.all = [animation]; + const effects = animation.animators.effects = new h.context.EffectAnimator(animation); + const add = (effect, locator = 'actor1_d', extra = {}) => effects.createKeyframe({ data_points: [{ effect, locator, ...extra }] }, 1, 'particle').data_points[0]; + return { ...h, add, sprite }; +} + +test('NoN preview presets are one-shot, world-space particles with a one-pixel spawn offset', () => { + const h = harness(), png = Buffer.alloc(24); + png.writeUInt32BE(16, 16); png.writeUInt32BE(80, 20); + const texture = `data:image/png;base64,${png.toString('base64')}`; + for (const id of ['liquid_particle', 'liquid_particle_falling', 'smallheart', 'ripped_fabric']) { + const json = h.buildParticlePreview(`needsofnature:${id}`, texture), c = json.particle_effect.components; + assert.equal(c['minecraft:emitter_rate_instant'].num_particles, 1); + assert.ok(c['minecraft:emitter_lifetime_once'].active_time > 0); + assert.equal(c['minecraft:emitter_local_space'].position, false); + assert.equal(c['minecraft:emitter_shape_point'].offset[1], -1 / 16); + if (id === 'smallheart') assert.equal(c['minecraft:particle_initial_speed'], 2); + if (id === 'liquid_particle_falling') { + assert.equal(c['minecraft:particle_motion_dynamic'].linear_acceleration[1], -2.4); + assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.12); + } + if (id === 'liquid_particle') { + assert.match(c['minecraft:particle_motion_parametric'].relative_position[1], /particle_age < 1/); + assert.match(c['minecraft:particle_motion_parametric'].relative_position[1], /-1\.2 \*/); + assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.12); + } + if (id === 'ripped_fabric') { + assert.equal(c['minecraft:particle_appearance_billboard'].uv.flipbook.max_frame, 5); + assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.19); + assert.equal(c['minecraft:particle_motion_dynamic'].linear_acceleration[1], -0.16); + } + } + assert.equal(h.buildParticlePreview('minecraft:dust', texture), null); + assert.equal(h.buildParticlePreview('needsofnature:smallheart', ''), null); +}); + +test('native preview mapping preserves gameplay cues, locators and manual preview files', (t) => { + const h = previewHarness(t); + const heart = h.add('needsofnature:smallheart', 'actor1_d', { script: 'v.custom = 1;' }); + const manual = h.add('needsofnature:smallheart', 'actor1_d', { file: 'manual.json' }); + const missingLocator = h.add('needsofnature:liquid_particle', 'missing'); + const unknown = h.add('minecraft:dust'); + h.mapAnimationParticles(h.context.Animation.all); + const key = `${h.PARTICLE_PREVIEW_PREFIX}needsofnature:smallheart`; + const native = h.context.Animator.particle_effects[key]; + assert.ok(native); + assert.equal(native.config.scene, h.context.WinterskyScene); + assert.equal(native.config.preview_texture, `data:image/png;base64,${h.sprite.toString('base64')}`); + assert.equal(heart.file, key); + assert.equal(heart.effect, 'needsofnature:smallheart'); + assert.equal(heart.locator, 'actor1_d'); + assert.equal(heart.script, 'v.custom = 1;'); + assert.equal(manual.file, 'manual.json'); + assert.ok(!missingLocator.file); + assert.ok(!unknown.file); + h.mapAnimationParticles(h.context.Animation.all); + assert.equal(h.context.Animator.particle_effects[key], native, 'Repeated selection reuses the config'); + heart.effect = 'needsofnature:liquid_particle_falling'; h.mapAnimationParticles(h.context.Animation.all); + assert.equal(heart.file, `${h.PARTICLE_PREVIEW_PREFIX}needsofnature:liquid_particle_falling`); + h.context.Locator.all = []; h.mapAnimationParticles(h.context.Animation.all); + assert.equal(heart.file, '', 'Missing locators do not preview at the actor origin'); +}); + +test('disabling and refreshing previews disposes emitters and regenerates saved virtual references', (t) => { + const h = previewHarness(t), point = h.add('needsofnature:smallheart'); + h.mapAnimationParticles(h.context.Animation.all); + const key = point.file, native = h.context.Animator.particle_effects[key]; + let deleted = 0; + native.emitters.test = { delete() { deleted++; } }; + h.setParticlePreviewsEnabled(false); + assert.equal(deleted, 1); + assert.equal(native.config.texture.disposed, true); + assert.equal(h.context.Animator.particle_effects[key], undefined); + assert.equal(point.file, ''); + assert.equal(h.storage.get('animationdirector_particle_previews'), 'false'); + point.file = key; point[h.PARTICLE_PREVIEW_PROPERTY] = key; + h.mapAnimationParticles(h.context.Animation.all); + assert.equal(point.file, '', 'Disabled previews remove saved virtual references'); + h.setParticlePreviewsEnabled(true); + assert.equal(point.file, key); + const next = h.context.Animator.particle_effects[key]; + h.loadParticleLibrary(); + assert.equal(next.config.texture.disposed, true); + assert.equal(point.file, ''); + h.mapAnimationParticles(h.context.Animation.all); + assert.equal(point.file, key); + h.clearParticlePreviews(); assert.equal(point.file, ''); +}); + +test('particle preview fields are removed from every exported cue without changing the source project', () => { + const h = harness(), source = { particle_effects: { '0.1': [{ effect: 'needsofnature:smallheart', + locator: 'actor1_d', script: 'test', file: 'virtual.json', [h.PARTICLE_PREVIEW_PROPERTY]: 'virtual.json' }], + '0.2': { effect: 'needsofnature:liquid_particle', locator: 'actor2_d', file: 'virtual.json' } } }; + const exported = h.cleanEffectPreviewFields(JSON.parse(JSON.stringify(source))); + assert.equal(exported.particle_effects['0.1'][0].file, undefined); + assert.equal(exported.particle_effects['0.1'][0][h.PARTICLE_PREVIEW_PROPERTY], undefined); + assert.equal(exported.particle_effects['0.1'][0].locator, 'actor1_d'); + assert.equal(exported.particle_effects['0.1'][0].script, 'test'); + assert.equal(exported.particle_effects['0.2'].effect, 'needsofnature:liquid_particle'); + assert.equal(source.particle_effects['0.1'][0].file, 'virtual.json'); +});