diff --git a/AnimationDirector.js b/AnimationDirector.js index bcf8996..51eaa56 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -26,6 +26,8 @@ let autoplayTransitioning = false; 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'; + let modelVariantsProjectProperty; const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' }; let propAction, propProjectProperty, propDialog, propListener; const propMeshes = new Map(); @@ -2755,6 +2757,75 @@ } catch (error) { Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); } } + function buildGenderModelVariant(model, hiddenBones) { + const output = clone(model); + for (const geometry of output['minecraft:geometry'] || []) { + const hidden = new Set(hiddenBones); + const bones = geometry.bones || []; + let previousSize; + do { + 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; + } + return output; + } + + function hiddenModelAncestor(name, parents, selected) { + const visited = new Set([name]); + let parent = parents.get(name); + while (parent && !visited.has(parent)) { + if (selected.has(parent)) return parent; + visited.add(parent); + parent = parents.get(parent); + } + return null; + } + + function refreshModelVariantFields(dialog, bones, result) { + const parents = new Map(bones.map((bone) => [bone.name, bone.parent])); + const updates = {}; + for (const gender of ['m', 'f', 'mf']) { + const section = dialog.modelVariantBoneSections?.[gender]; + if (section) section.style.display = result[gender] ? '' : 'none'; + const selected = new Set(bones.filter((bone, index) => result[`hide_${gender}_${index}`]).map((bone) => bone.name)); + bones.forEach((bone, index) => { + const key = `hide_${gender}_${index}`; + const ancestor = hiddenModelAncestor(bone.name, parents, selected); + if (ancestor && result[key]) result[key] = updates[key] = false; + 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}` : ''; + for (const input of field.bar.querySelectorAll('input')) input.disabled = !!ancestor; + }); + } + if (Object.keys(updates).length) dialog.form.setValues(updates, false); + } + + function createModelVariantBoneSections(dialog, bones) { + dialog.modelVariantBoneSections = {}; + for (const gender of ['m', 'f', 'mf']) { + const bars = bones.map((bone, index) => dialog.form?.form_data?.[`hide_${gender}_${index}`]?.bar).filter(Boolean); + const first = bars[0]; + if (!first?.parentNode || !first.ownerDocument) continue; + const section = first.ownerDocument.createElement('div'); + section.className = 'animationdirector_variant_bones'; + section.style.maxHeight = '160px'; + section.style.overflowY = 'auto'; + section.style.overflowX = 'hidden'; + section.style.border = '1px solid var(--color-border)'; + section.style.padding = '4px 8px'; + section.style.marginBottom = '12px'; + first.parentNode.insertBefore(section, first); + // Keep the native form fields and their event handlers intact. + for (const bar of bars) section.appendChild(bar); + dialog.modelVariantBoneSections[gender] = section; + } + } + function exportGeckoModel() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); @@ -2766,16 +2837,66 @@ const afwBoneTextures = buildAfwBoneTextureMap(); const output = addAfwBoneTextureMapToGeckoJson(compiled, afwBoneTextures); - Blockbench.export( - { - type: 'GeckoLib Model JSON', - extensions: ['json', 'geo.json'], - name: `${getModelExportFileName()}.geo`, - content: stringifyCompactModelJson(output, 2), - savetype: 'text', + 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'] || []) + .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.' }, + }; + 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[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):', + condition: (result) => !!result[gender] }; + bones.forEach((bone, index) => { + form[`hide_${gender}_${index}`] = { type: 'checkbox', 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, + 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, ''); + 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.'); + const settings = { name, base: !!result.base }; + const parents = new Map(bones.map((bone) => [bone.name, bone.parent])); + for (const gender of ['m', 'f', 'mf']) { + settings[gender] = !!result[gender]; + 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.saved = false; + const files = [...(result.base ? [{ name, model: output }] : []), ...genders.map((gender) => ({ + name: `${name}.${gender}`, model: 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' }, + () => exportNext(index + 1)); + }; + exportNext(0); + } catch (error) { + Blockbench.showMessageBox({ title: 'Export GeckoLib Models', message: error.message }); + return false; + } }, - () => Blockbench.showQuickMessage('Exported GeckoLib model', 2500) - ); + }); + dialog.show(); + createModelVariantBoneSections(dialog, bones); + refreshModelVariantFields(dialog, bones, dialog.getFormResult()); } catch (e) { console.error(e); Blockbench.showMessageBox({ @@ -6938,10 +7059,13 @@ 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 the current model as GeckoLib/Bedrock geometry with AFW bone texture overrides.', + description: 'Export GeckoLib geometry, including m/f/mf variants with separate hidden-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: '{}' }); + } createJemModelAction = new Action('animationdirector_create_jem_model', { condition: () => !Blockbench.isWeb, name: 'Create JEM Model', icon: 'icon-format_optifine', @@ -7093,6 +7217,7 @@ Features without a reference need no additional addon. Whole-animation import an if (actorAnimdefMetadataProjectProperty?.delete) actorAnimdefMetadataProjectProperty.delete(); if (animdefAnimatorsProjectProperty?.delete) animdefAnimatorsProjectProperty.delete(); if (animdefDefinitionsProjectProperty?.delete) animdefDefinitionsProjectProperty.delete(); + modelVariantsProjectProperty?.delete(); if (blockRequirementsProjectProperty?.delete) blockRequirementsProjectProperty.delete(); }, });