added multi gender export with exclusion for optifine models

This commit is contained in:
2026-10-01 01:58:07 +02:00
parent c1c94a7e16
commit 3e26c7a657
3 changed files with 610 additions and 26 deletions
+103 -26
View File
@@ -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();
},
});