diff --git a/AnimationDirector.js b/AnimationDirector.js index 1f255fc..a7c8e17 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -19,6 +19,7 @@ let importAnimationDefinitionAction; let automaticImportAction; let packFoldersAction; + let createJemModelAction; let previousStagePoseAction; let loopPoseAction; let autoplayStagesAction, autoplayLoopOriginal, autoplayLoopWrapper; @@ -2315,6 +2316,311 @@ return sanitizeFilePart(String(name).replace(/\.geo\.json$/i, '').replace(/\.json$/i, '')) || 'model'; } + async function cemTemplateCatalog() { + if (typeof window === 'undefined' || typeof window.loadCEMTemplateModels !== 'function') { + throw new Error('Install and enable CEM Template Loader before creating a JEM model.'); + } + if (typeof MinecraftEULA !== 'undefined' && !await MinecraftEULA.promptUser('cem_template_loader')) return null; + // The first loader call initializes its UI; subsequent calls return the shared catalog promise. + let catalog = await window.loadCEMTemplateModels(); + if (!catalog?.models) catalog = await window.loadCEMTemplateModels(); + if (!catalog?.models || !Array.isArray(catalog.entities)) { + throw new Error('CEM Template Loader did not expose a usable preset catalog. Update it or retry after its presets have loaded.'); + } + return catalog; + } + + function jemPresetParts(model) { + const parts = []; + function visit(node, name, path) { + parts.push({ name, path }); + for (const [index, child] of (node.submodels || []).entries()) { + const childName = child.id || child.comment; + if (childName) visit(child, childName, `${path}/${index}`); + } + } + for (const [index, part] of (model.models || []).entries()) { + if (part.part) visit(part, part.part, String(index)); + } + return parts; + } + + function jemTransferSuggestions(groups, parts, actorIndex) { + const prefix = actorIndex ? `actor${actorIndex}_` : ''; + const nameOf = (group) => prefix && group.name.startsWith(prefix) ? group.name.slice(prefix.length) : group.name; + const normalized = (name) => String(name).toLowerCase().replace(/[ _-]/g, ''); + const match = (group) => { + const matches = parts.filter((part) => normalized(part.name) === normalized(nameOf(group))); + return matches.length === 1 ? matches[0] : null; + }; + const groupSet = new Set(groups), containsPreset = new Map(); + function hasPreset(group) { + if (!containsPreset.has(group)) containsPreset.set(group, !!match(group) + || (group.children || []).some((child) => groupSet.has(child) && hasPreset(child))); + return containsPreset.get(group); + } + const additions = groups.filter((group) => !hasPreset(group) + && (!groupSet.has(group.parent) || hasPreset(group.parent))); + return additions.map((root) => { + let ancestor = root.parent, mapped; + while (groupSet.has(ancestor)) { + mapped = match(ancestor); + if (mapped) break; + ancestor = ancestor.parent; + } + return { root, name: nameOf(root), target: mapped?.path || '', + sourceParent: groupSet.has(root.parent) ? root.parent : null, nameOf }; + }); + } + + function jemAdditionBones(suggestion) { + const bones = []; + function visit(group, path) { + bones.push({ group, path, name: suggestion.nameOf(group) }); + group.children.filter((child) => child instanceof Group).forEach((child, index) => visit(child, `${path}_${index}`)); + } + visit(suggestion.root, 'root'); + return bones; + } + + function snapshotJemAddition(suggestion, result = {}, index = 0) { + const enabled = new Map(jemAdditionBones(suggestion).map((bone) => [bone.group, + !!result[`energy_${index}_${bone.path}`] && !jemEnergyInherited(bone.path, result, index)])); + const serialize = (group) => ({ name: suggestion.nameOf(group), origin: [...group.origin], rotation: [...group.rotation], + energyVisibility: enabled.get(group), + children: group.children.filter((child) => child instanceof Group || child instanceof Cube).map((child) => + child instanceof Group ? { group: serialize(child) } : { cube: clone(child.getSaveCopy()) }) }); + return { tree: serialize(suggestion.root), parentOrigin: [...(suggestion.sourceParent?.origin || [0, 0, 0])] }; + } + + function jemEnergyInherited(path, result, index) { + let parent = path; + while (parent.includes('_')) { + parent = parent.slice(0, parent.lastIndexOf('_')); + if (result[`energy_${index}_${parent}`]) return true; + } + return false; + } + + function refreshJemEnergyFields(dialog, suggestions, result) { + suggestions.forEach((suggestion, index) => { + for (const bone of jemAdditionBones(suggestion)) { + const field = dialog.form?.form_data?.[`energy_${index}_${bone.path}`]; + if (!field?.bar) continue; + const inherited = jemEnergyInherited(bone.path, result, index); + for (const input of field.bar.querySelectorAll('input')) input.disabled = inherited; + field.bar.style.opacity = inherited ? '0.4' : ''; + field.bar.title = inherited ? 'Energy visibility is inherited from a selected parent bone.' : ''; + } + }); + } + + function addJemEnergyVisibility(group) { + let root = group; + while (root.parent instanceof Group) root = root.parent; + if (!Array.isArray(root.cem_animations)) root.cem_animations = []; + root.cem_animations.push({ [`${group.name}.visible`]: 'nbt(NeedsOfNatureEnergy,range:70-200)' }); + } + + function validateJemChoices(suggestions, result) { + const selected = []; + suggestions.forEach((suggestion, index) => { + if (!result[`include_${index}`]) return; + const target = result[`parent_${index}`]; + if (!target) throw new Error(`Choose a target parent for ${suggestion.name}, or uncheck that addition.`); + selected.push({ ...snapshotJemAddition(suggestion, result, index), target }); + }); + if (!selected.length) throw new Error('Select at least one added bone to transfer.'); + return selected; + } + + function createJemTransferProject(preset, additions, textures, baseTextureId, sourceUv, exportNow) { + const model = clone(preset.model); + // Texture pixels come from the source project, not remote template image URLs. + function stripTextures(node) { + delete node.texture; + for (const child of node.submodels || []) stripTextures(child); + } + stripTextures(model); + for (const part of model.models || []) stripTextures(part); + if (!newProject(Formats.optifine_entity)) throw new Error('Could not create the OptiFine project.'); + Project.name = preset.name; + Formats.optifine_entity.codec.parse(model, ''); + const targetGroups = Group.all.slice(); + const destinations = new Map(); + const roots = targetGroups.filter((group) => !(group.parent instanceof Group)); + function collect(group, path) { + destinations.set(path, group); + group.children.filter((child) => child instanceof Group).forEach((child, index) => collect(child, `${path}/${index}`)); + } + roots.forEach((group, index) => collect(group, String(index))); + const importedTextures = new Map(); + for (const texture of textures) { + const data = { ...texture.data, name: texture.name, layers_enabled: false }; + delete data.uuid; delete data.layers; + const copy = new Texture(data).fromDataURL(texture.image).add(false); + copy.use_as_default = texture.id === baseTextureId; + importedTextures.set(texture.id, copy); + } + const base = importedTextures.get(baseTextureId); + if (base) for (const group of targetGroups) group.texture = base.uuid; + const names = new Set(targetGroups.map((group) => group.name)); + function uniqueName(name) { + let candidate = name, index = 2; + while (names.has(candidate)) candidate = `${name}_${index++}`; + names.add(candidate); return candidate; + } + for (const addition of additions) { + const parent = destinations.get(addition.target); + if (!parent) throw new Error(`The preset no longer contains parent ${addition.target}.`); + const offset = parent.origin.map((value, axis) => value - addition.parentOrigin[axis]); + const shifted = (point) => point.map((value, axis) => value + offset[axis]); + function copyTree(tree, destination) { + const group = new Group({ name: uniqueName(tree.name), origin: shifted(tree.origin), rotation: tree.rotation }).init(); + group.addTo(destination); + if (tree.energyVisibility) addJemEnergyVisibility(group); + for (const child of tree.children) { + if (child.group) { copyTree(child.group, group); continue; } + const data = clone(child.cube); + delete data.uuid; + data.name = data.name.replace(/^actor\d+_/, ''); + data.from = shifted(data.from); data.to = shifted(data.to); + if (data.origin) data.origin = shifted(data.origin); + const cube = new Cube(data).addTo(group).init(); + // CEM supports one texture per group; create a dedicated attachment + // for each textured cube so differing source atlases stay independent. + const sourceId = Object.values(data.faces || {}).find((face) => typeof face.texture === 'string')?.texture || baseTextureId; + const texture = importedTextures.get(sourceId) || base; + if (texture) { + const textureGroup = new Group({ name: uniqueName(`${group.name}_texture`), origin: [...group.origin], texture: texture.uuid }).init(); + textureGroup.addTo(group); cube.addTo(textureGroup); + if (cube.box_uv) cube.setUVMode(false); + for (const face of Object.values(cube.faces)) { + if (face.texture === null) continue; + face.texture = texture.uuid; + face.uv = face.uv.map((value, axis) => value * (axis % 2 ? texture.uv_height / sourceUv.height : texture.uv_width / sourceUv.width)); + } + } + } + } + copyTree(addition.tree, parent); + } + Project.saved = false; + Canvas.updateAll(); + if (exportNow) Formats.optifine_entity.codec.export(); + else Blockbench.showQuickMessage('Created JEM project with the selected added bones.', 3000); + } + + function showJemTransferReview(catalog, actorIndex, entityId, sourceProject) { + const entity = catalog.entities.find((entry) => entry.id === entityId); + const raw = catalog.models[entity?.model || entityId]?.model; + if (!raw) throw new Error('The selected entity has no CEM preset.'); + const model = typeof raw === 'string' ? JSON.parse(raw) : clone(raw); + const parts = jemPresetParts(model); + const groups = Group.all.filter((group) => (!actorIndex || readActorIndexFromName(group.name) === actorIndex) && !isBlockRequirementGuide(group)); + const suggestions = jemTransferSuggestions(groups, parts, actorIndex); + if (!suggestions.length) throw new Error('No added bone subtrees were found compared with this preset. Modified vanilla cubes are not transferred.'); + const form = { info: { type: 'info', text: '### Transfer added bones\nUnmatched bones are suggestions, not proof of new geometry. Uncheck renamed vanilla parts. The vanilla preset remains intact; animation keyframes and locators are not copied.' } }; + const options = Object.fromEntries([['', 'Choose target parent'], ...parts.map((part) => [part.path, `${part.name} (${part.path})`])]); + suggestions.forEach((suggestion, index) => { + form[`section_${index}`] = { type: 'info', full_width: true, text: `${index ? '---\n' : ''}### ${suggestion.name}` }; + form[`include_${index}`] = { type: 'checkbox', label: suggestion.name, value: true }; + form[`parent_${index}`] = { type: 'select', label: `Parent for ${suggestion.name}`, options, value: suggestion.target, + condition: (result) => result[`include_${index}`] }; + for (const bone of jemAdditionBones(suggestion)) { + form[`energy_${index}_${bone.path}`] = { type: 'checkbox', label: `Energy visibility: ${bone.name}`, + value: false, description: 'Show this bone only at NoN energy 70-200. Hiding a parent also hides its children.', + condition: (result) => result[`include_${index}`] }; + } + }); + let busy = false; + const dialog = new Dialog({ id: 'animationdirector_create_jem_review', title: 'Create JEM Model', width: 650, form, + buttons: ['Cancel', 'Create Project', 'Export Model'], cancelIndex: 0, confirmIndex: 1, + onFormChange(result) { refreshJemEnergyFields(dialog, suggestions, result); }, + onButton(index) { + if (index !== 1 && index !== 2) return; + if (busy) return false; + try { + if (Project !== sourceProject) throw new Error('Return to the source project before transferring its geometry.'); + const additions = validateJemChoices(suggestions, dialog.getFormResult()); + let base = actorIndex ? findTextureByActorTextureName(getProjectActorTextures()[actorIndex]) : Texture.getDefault(); + if (!base) { + const counts = new Map(); + for (const cube of Cube.all) { + if (actorIndex && !cubeBelongsToActor(cube, actorIndex)) continue; + const texture = getCubeReadableTexture(cube); + if (texture) counts.set(texture, (counts.get(texture) || 0) + 1); + } + base = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0]; + } + const ids = new Set(base ? [base.uuid] : []); + function textureIds(tree) { + for (const child of tree.children) { + if (child.group) { textureIds(child.group); continue; } + const cubeIds = new Set(Object.values(child.cube.faces || {}).map((face) => face.texture).filter((id) => typeof id === 'string')); + if (cubeIds.size > 1) throw new Error(`Cube ${child.cube.name} uses multiple textures. CEM requires one texture per cube; separate it before transferring.`); + for (const id of cubeIds) ids.add(id); + } + } + additions.forEach((addition) => textureIds(addition.tree)); + const textures = [...ids].map((id) => { + const texture = findTextureById(id); + if (!texture || texture.error) throw new Error('A selected addition uses a missing texture. Load that texture before transferring.'); + return { id, name: texture.name, data: texture.getSaveCopy(), image: texture.getDataURL() }; + }); + busy = true; + createJemTransferProject({ model, name: entity.file || entity.id }, additions, textures, base?.uuid, + { width: Project.texture_width, height: Project.texture_height }, index === 2); + } catch (error) { + busy = false; + Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); + return false; + } + }, + }); + dialog.show(); + suggestions.forEach((suggestion, index) => { + const bar = dialog.form?.form_data?.[`section_${index}`]?.bar; + if (!bar) return; + bar.style.display = 'block'; + const content = bar.querySelector('.small_text'); + if (content) content.style.width = '100%'; + const separator = bar.querySelector('hr'); + if (separator) { + separator.style.border = '0'; + separator.style.borderTop = '1px solid #777777'; + separator.style.width = '100%'; + } + }); + } + + async function showCreateJemModel() { + try { + if (!Project) throw new Error('Open a model project first.'); + 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(); + if (!catalog) return; + if (Project !== sourceProject) throw new Error('The source project changed while loading the presets. Please retry.'); + const actors = buildActorSelectOptions(); + if (!Object.keys(actors).length) actors.model = 'Current model'; + const first = Object.keys(actors)[0]; + const entity = first === 'model' ? '' : actorAnimdefMetadataWithInference(Number(first), getProjectActorAnimdefMetadata()[first]).entity_types?.[0]?.split(':').pop(); + const presets = Object.fromEntries(catalog.entities.filter((entry) => catalog.models[entry.model || entry.id]?.model) + .map((entry) => [entry.id, entry.name || entry.id])); + const dialog = new Dialog({ id: 'animationdirector_create_jem', title: 'Create JEM Model', form: { + actor: { type: 'select', label: 'Source actor', options: actors, value: first }, + preset: { type: 'select', label: 'CEM entity preset', options: presets, value: presets[entity] ? entity : Object.keys(presets)[0] }, + }, onConfirm(result) { + try { + if (Project !== sourceProject) throw new Error('Return to the source model project and retry.'); + showJemTransferReview(catalog, result.actor === 'model' ? null : Number(result.actor), result.preset, sourceProject); + } catch (error) { Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); return false; } + } }); + dialog.show(); + } catch (error) { Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); } + } + function exportGeckoModel() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); @@ -6074,6 +6380,11 @@ You can import or export complete AFW animation IDs as one file containing all s icon: 'file_upload', click: exportGeckoModel, }); + createJemModelAction = new Action('animationdirector_create_jem_model', { + name: 'Create JEM Model', icon: 'icon-format_optifine', + description: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.', + click: showCreateJemModel, + }); exportConjoinedAction = new Action('export_conjoined_actor_animations', { name: 'Export Animations', @@ -6113,7 +6424,7 @@ You can import or export complete AFW animation IDs as one file containing all s id: 'animationdirector_model', name: 'AnimationDirector Model', icon: 'deployed_code', - children: [boneTextureOverridesAction, exportGeckoModelAction], + children: [boneTextureOverridesAction, exportGeckoModelAction, createJemModelAction], }); MenuBar.menus.tools.addAction({ @@ -6178,6 +6489,7 @@ You can import or export complete AFW animation IDs as one file containing all s actorPreferencesAction.delete(); boneTextureOverridesAction.delete(); exportGeckoModelAction.delete(); + createJemModelAction?.delete(); replaceActorAction.delete(); exportConjoinedAction.delete(); createAnimationDefinitionAction.delete();