export jem model
This commit is contained in:
+313
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user