added multi gender export with exclusion for optifine models
This commit is contained in:
+103
-26
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user