export jem model

This commit is contained in:
2026-09-29 14:19:24 +02:00
parent 4735c33e6e
commit 297dcd82da
+313 -1
View File
@@ -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();