This commit is contained in:
2026-09-29 14:54:06 +02:00
parent f7c0c38ec2
commit 5cb0ba631e
6 changed files with 462 additions and 166 deletions
+153 -61
View File
@@ -936,9 +936,21 @@
Project.saved = false; Project.saved = false;
} }
function blockRequirementPreviewDefaults(requirement) {
const blocks = Array.isArray(requirement.blocks) ? requirement.blocks : requirement.blocks ? [requirement.blocks] : [];
const beds = /^(?:#minecraft:beds|minecraft:(?:white|orange|magenta|light_blue|yellow|lime|pink|gray|light_gray|cyan|purple|blue|brown|green|red|black)_bed)$/;
const fences = /^(?:#minecraft:(?:fences|wooden_fences)|minecraft:(?:oak|spruce|birch|jungle|acacia|dark_oak|mangrove|cherry|bamboo|pale_oak|crimson|warped|nether_brick)_fence)$/;
return {
wallThickness: requirement.type === 'wall' && blocks.length && blocks.every((block) => fences.test(block)) ? 0.25 : 1,
surfaceHeight: requirement.type === 'center_support' && blocks.length && blocks.every((block) => beds.test(block)) ? 0.5625 : 1,
};
}
function buildBlockRequirementForm(animationId, importedRequirement) { function buildBlockRequirementForm(animationId, importedRequirement) {
const entry = getAuthoredBlockRequirement(animationId); const entry = getAuthoredBlockRequirement(animationId);
const req = importedRequirement ?? (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) ?? {}; const req = importedRequirement ?? (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) ?? {};
const preview = importedRequirement !== undefined ? blockRequirementPreviewDefaults(req)
: entry?.preview ?? blockRequirementPreviewDefaults(req);
const wall = (result) => result?.type === 'wall'; const wall = (result) => result?.type === 'wall';
const center = (result) => result?.type === 'center_support'; const center = (result) => result?.type === 'center_support';
const directional = (result) => wall(result) || result?.placement !== 'surface_footprint'; const directional = (result) => wall(result) || result?.placement !== 'surface_footprint';
@@ -949,7 +961,7 @@
const form = { const form = {
_about: { type: 'info', text: `### Block requirement: ${animationId}\nBlue = required structure/floor. Red = clearance. ` _about: { type: 'info', text: `### Block requirement: ${animationId}\nBlue = required structure/floor. Red = clearance. `
+ 'Guides show the minimum valid layout at the animation origin (16 pixels = 1 block). Clearance extends toward +Z; actors face the wall toward -Z. ' + 'Guides show the minimum valid layout at the animation origin (16 pixels = 1 block). Clearance extends toward +Z; actors face the wall toward -Z. '
+ 'Guides are excluded from model and animation exports; the requirement is included in Create Animation Definition.' }, + 'Guides are excluded from model and animation exports; the requirement is included in Animation Definition.' },
type: { label: 'Requirement type', type: 'select', value: req.type || 'wall', options: { wall: 'Wall', center_support: 'Center support' } }, type: { label: 'Requirement type', type: 'select', value: req.type || 'wall', options: { wall: 'Wall', center_support: 'Center support' } },
blocks: { label: 'Block IDs / tags', type: 'text', value: (Array.isArray(req.blocks) ? req.blocks : req.blocks ? [req.blocks] : []).join(', '), blocks: { label: 'Block IDs / tags', type: 'text', value: (Array.isArray(req.blocks) ? req.blocks : req.blocks ? [req.blocks] : []).join(', '),
placeholder: '#minecraft:fences, minecraft:stone', description: 'Comma-separated alternatives. Leave empty for any supported wall/support. Required for custom surfaces and facing.' }, placeholder: '#minecraft:fences, minecraft:stone', description: 'Comma-separated alternatives. Leave empty for any supported wall/support. Required for custom surfaces and facing.' },
@@ -980,9 +992,9 @@
clearance_height: dimension('Clearance height', req.clearance?.height ?? 2, directional), clearance_height: dimension('Clearance height', req.clearance?.height ?? 2, directional),
clearance_depth: dimension('Clearance depth', req.clearance?.depth ?? 1, directional), clearance_depth: dimension('Clearance depth', req.clearance?.depth ?? 1, directional),
_preview: { type: 'info', text: '### Preview shape (editor only)\nActual collision shapes depend on the block state. These values adjust only the guide, not the exported requirement. Maximum limits/facing remain matching rules; the guide shows the minimum structure.' }, _preview: { type: 'info', text: '### Preview shape (editor only)\nActual collision shapes depend on the block state. These values adjust only the guide, not the exported requirement. Maximum limits/facing remain matching rules; the guide shows the minimum structure.' },
wall_thickness: { label: 'Wall thickness (blocks)', type: 'number', value: entry?.preview?.wallThickness ?? 1, min: 0.0625, max: 1, step: 0.0625, condition: wall, wall_thickness: { label: 'Wall thickness (blocks)', type: 'number', value: preview.wallThickness ?? 1, min: 0.0625, max: 1, step: 0.0625, condition: wall,
description: 'Full block: 1. Fence post: 0.25. Adjust to the intended collision surface.' }, description: 'Full block: 1. Fence post: 0.25. Adjust to the intended collision surface.' },
surface_height: { label: 'Custom surface height', type: 'number', value: entry?.preview?.surfaceHeight ?? 1, min: 0.0625, max: 2, step: 0.0625, surface_height: { label: 'Custom surface height', type: 'number', value: preview.surfaceHeight ?? 1, min: 0.0625, max: 2, step: 0.0625,
condition: (result) => center(result) && result?.support === 'surface', description: 'For example a bed is 0.5625 blocks high; slab/half-height mode uses 0.5.' }, condition: (result) => center(result) && result?.support === 'surface', description: 'For example a bed is 0.5625 blocks high; slab/half-height mode uses 0.5.' },
}; };
return form; return form;
@@ -1207,11 +1219,8 @@
} }
function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false, manageUndo = true) { function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false, manageUndo = true) {
const bones = Array.isArray(geo?.bones) ? geo.bones : []; validateImportGeometry({ 'minecraft:geometry': [geo] });
if (!bones.length) { const bones = geo.bones;
Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
return;
}
const prefix = `actor${actorIndex}_`; const prefix = `actor${actorIndex}_`;
const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null; const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null;
@@ -1219,6 +1228,8 @@
if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: Cube.all.slice(), textures: [], if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: Cube.all.slice(), textures: [],
uv_mode: true, actor_uv_edit: true, uv_mode: true, actor_uv_edit: true,
animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] }); animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] });
let editing = manageUndo;
try {
if (replaceExisting) removeActorGeometry(actorIndex); if (replaceExisting) removeActorGeometry(actorIndex);
const uvWidth = validUvDimension(geo?.description?.texture_width); const uvWidth = validUvDimension(geo?.description?.texture_width);
const uvHeight = validUvDimension(geo?.description?.texture_height); const uvHeight = validUvDimension(geo?.description?.texture_height);
@@ -1316,7 +1327,10 @@
applyAllActorTexturePreviews(false); applyAllActorTexturePreviews(false);
} }
if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select(); if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select();
if (manageUndo) Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`); if (manageUndo) {
Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`);
editing = false;
}
const animationNotice = replaceExisting && animationResult.rebound const animationNotice = replaceExisting && animationResult.rebound
? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}` ? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}`
@@ -1330,6 +1344,10 @@
buttons: ['OK'], buttons: ['OK'],
}); });
} }
} catch (error) {
if (editing) Undo.cancelEdit();
throw error;
}
} }
function pngDimensions(bytes) { function pngDimensions(bytes) {
@@ -1382,12 +1400,9 @@
try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ } try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ }
try { sourcePaths.push(...savedPackFolders()); } catch (_) { /* No registered packs. */ } try { sourcePaths.push(...savedPackFolders()); } catch (_) { /* No registered packs. */ }
try { sourcePaths.push(...discoverPropSources(io).map((source) => source.file)); } catch (_) { /* No cached assets. */ } try { sourcePaths.push(...discoverPropSources(io).map((source) => source.file)); } catch (_) { /* No cached assets. */ }
const sources = []; const sources = createImportAssetLookup(io, [...new Set(sourcePaths)]);
for (const sourcePath of [...new Set(sourcePaths)]) {
try { sources.push(openPropSource(sourcePath, io)); } catch (_) { /* Try other asset sources. */ }
}
for (const id of candidateIds) { for (const id of candidateIds) {
for (const source of sources) { for (const source of sources.sources()) {
const parsed = importResourceId(id); const parsed = importResourceId(id);
const relative = `assets/${parsed.namespace}/${parsed.resource}`; const relative = `assets/${parsed.namespace}/${parsed.resource}`;
let bytes; let bytes;
@@ -1880,28 +1895,33 @@
const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name); const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name);
const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0; const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || texture.uv_width || 0;
const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0; const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || texture.uv_height || 0;
if (actorIndex && imageWidth > 0 && imageHeight > 0) normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight); const uvChanged = actorIndex && imageWidth > 0 && imageHeight > 0
? normalizeCubeUvs(cube, actorIndex, imageWidth, imageHeight) : false;
const faces = ['north', 'south', 'east', 'west', 'up', 'down']; const faces = ['north', 'south', 'east', 'west', 'up', 'down'];
const textureId = texture.uuid || texture.id || texture.name; const textureId = texture.uuid || texture.id || texture.name;
let facesChanged = false;
if (textureId && cube.faces) { if (textureId && cube.faces) {
for (const face of faces) { for (const face of faces) {
const cubeFace = cube.faces[face]; const cubeFace = cube.faces[face];
if (!cubeFace) continue; if (!cubeFace) continue;
cubeFace.texture = textureId; if (cubeFace.texture !== textureId) { cubeFace.texture = textureId; facesChanged = true; }
} }
} }
// In GeckoLib/single-texture formats, Blockbench can still render every cube with the selected/default texture. // In GeckoLib/single-texture formats, Blockbench can still render every cube with the selected/default texture.
// So after updating the normal face data, force the actual Three.js mesh material for preview only. // So after updating the normal face data, force the actual Three.js mesh material for preview only.
if (facesChanged || uvChanged) {
cube.preview_controller?.updateFaces?.(cube); cube.preview_controller?.updateFaces?.(cube);
cube.preview_controller?.updateUV?.(cube); cube.preview_controller?.updateUV?.(cube);
cube.preview_controller?.updateGeometry?.(cube); cube.preview_controller?.updateGeometry?.(cube);
}
forceActorTextureMaterial(cube, texture); forceActorTextureMaterial(cube, texture);
// Some preview updates are deferred internally, so apply the material again on the next tick. // Some preview updates are deferred internally, so apply the material again on the next tick.
setTimeout(() => forceActorTextureMaterial(cube, texture), 0); const projectId = Project?.uuid;
setTimeout(() => { if (Project?.uuid === projectId) forceActorTextureMaterial(cube, texture); }, 0);
} }
function applyActorTexturePreview(actorIndex, textureName) { function applyActorTexturePreview(actorIndex, textureName) {
@@ -2016,11 +2036,11 @@
clearScheduledActorTexturePreviewApplies(); clearScheduledActorTexturePreviewApplies();
if (!Project || !hasProjectActorTexturePreviews()) return; if (!Project || !hasProjectActorTexturePreviews()) return;
// Project loading can rebuild preview meshes and load textures after project events fire. const projectId = Project.uuid;
// Reapply for a short window so the saved actor texture preview wins over the selected texture. // Texture load callbacks and project events schedule their own refreshes.
for (const delay of [0, 50, 250, 1000, 2500, 5000, 10000]) { for (const delay of [0, 250]) {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (Project && hasProjectActorTexturePreviews()) applyAllActorTexturePreviews(false); if (Project?.uuid === projectId && hasProjectActorTexturePreviews()) applyAllActorTexturePreviews(false);
}, delay); }, delay);
actorTexturePreviewTimeouts.push(timeout); actorTexturePreviewTimeouts.push(timeout);
} }
@@ -2394,7 +2414,36 @@
energyVisibility: enabled.get(group), energyVisibility: enabled.get(group),
children: group.children.filter((child) => child instanceof Group || child instanceof Cube).map((child) => children: group.children.filter((child) => child instanceof Group || child instanceof Cube).map((child) =>
child instanceof Group ? { group: serialize(child) } : { cube: clone(child.getSaveCopy()) }) }); child instanceof Group ? { group: serialize(child) } : { cube: clone(child.getSaveCopy()) }) });
return { tree: serialize(suggestion.root), parentOrigin: [...(suggestion.sourceParent?.origin || [0, 0, 0])] }; return { tree: serialize(suggestion.root), parentOrigin: [...(suggestion.sourceParent?.origin || [0, 0, 0])],
parentRotation: jemParentRotation(suggestion.sourceParent), rotationOrder: Format.euler_order || 'ZYX' };
}
function jemParentRotation(group) {
const chain = [];
while (group instanceof Group) { chain.unshift(group); group = group.parent; }
const rotation = new THREE.Quaternion();
for (const parent of chain) {
rotation.multiply(jemRotationQuaternion(parent.rotation, Format.euler_order || 'ZYX'));
}
return rotation.toArray();
}
function jemRotationQuaternion(rotation, order) {
return new THREE.Quaternion().setFromEuler(new THREE.Euler(
...rotation.map((angle) => angle * Math.PI / 180), order));
}
function jemTransferTransform(addition, parent) {
const targetOrder = Format.euler_order || 'ZYX';
const rotation = new THREE.Quaternion().fromArray(jemParentRotation(parent)).invert()
.multiply(new THREE.Quaternion().fromArray(addition.parentRotation));
const origin = new THREE.Vector3(...addition.tree.origin)
.sub(new THREE.Vector3(...addition.parentOrigin)).applyQuaternion(rotation)
.add(new THREE.Vector3(...parent.origin)).toArray();
const euler = new THREE.Euler().setFromQuaternion(rotation.multiply(
jemRotationQuaternion(addition.tree.rotation, addition.rotationOrder)), targetOrder);
return { offset: origin.map((value, axis) => value - addition.tree.origin[axis]),
rotation: [euler.x, euler.y, euler.z].map((angle) => angle * 180 / Math.PI) };
} }
function jemEnergyInherited(path, result, index) { function jemEnergyInherited(path, result, index) {
@@ -2477,10 +2526,18 @@
for (const addition of additions) { for (const addition of additions) {
const parent = destinations.get(addition.target); const parent = destinations.get(addition.target);
if (!parent) throw new Error(`The preset no longer contains parent ${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]); // Compensate only the subtree root; rotating its children again would double the transform.
const transform = jemTransferTransform(addition, parent);
const offset = transform.offset;
const shifted = (point) => point.map((value, axis) => value + offset[axis]); const shifted = (point) => point.map((value, axis) => value + offset[axis]);
const convertedRotation = (rotation) => {
if (addition.rotationOrder === (Format.euler_order || 'ZYX')) return rotation;
const euler = new THREE.Euler().setFromQuaternion(jemRotationQuaternion(rotation, addition.rotationOrder), Format.euler_order || 'ZYX');
return [euler.x, euler.y, euler.z].map((angle) => angle * 180 / Math.PI);
};
function copyTree(tree, destination) { function copyTree(tree, destination) {
const group = new Group({ name: uniqueName(tree.name), origin: shifted(tree.origin), rotation: tree.rotation }).init(); const group = new Group({ name: uniqueName(tree.name), origin: shifted(tree.origin),
rotation: tree === addition.tree ? transform.rotation : convertedRotation(tree.rotation) }).init();
group.addTo(destination); group.addTo(destination);
if (tree.energyVisibility) addJemEnergyVisibility(group); if (tree.energyVisibility) addJemEnergyVisibility(group);
for (const child of tree.children) { for (const child of tree.children) {
@@ -2490,6 +2547,7 @@
data.name = data.name.replace(/^actor\d+_/, ''); data.name = data.name.replace(/^actor\d+_/, '');
data.from = shifted(data.from); data.to = shifted(data.to); data.from = shifted(data.from); data.to = shifted(data.to);
if (data.origin) data.origin = shifted(data.origin); if (data.origin) data.origin = shifted(data.origin);
if (data.rotation) data.rotation = convertedRotation(data.rotation);
const cube = new Cube(data).addTo(group).init(); const cube = new Cube(data).addTo(group).init();
// CEM supports one texture per group; create a dedicated attachment // CEM supports one texture per group; create a dedicated attachment
// for each textured cube so differing source atlases stay independent. // for each textured cube so differing source atlases stay independent.
@@ -2502,11 +2560,13 @@
for (const face of Object.values(cube.faces)) { for (const face of Object.values(cube.faces)) {
if (face.texture === null) continue; if (face.texture === null) continue;
face.texture = texture.uuid; face.texture = texture.uuid;
if (sourceUv.projectWide) {
face.uv = face.uv.map((value, axis) => value * (axis % 2 ? texture.uv_height / sourceUv.height : texture.uv_width / sourceUv.width)); face.uv = face.uv.map((value, axis) => value * (axis % 2 ? texture.uv_height / sourceUv.height : texture.uv_width / sourceUv.width));
} }
} }
} }
} }
}
copyTree(addition.tree, parent); copyTree(addition.tree, parent);
} }
Project.saved = false; Project.saved = false;
@@ -2574,7 +2634,7 @@
}); });
busy = true; busy = true;
createJemTransferProject({ model, name: entity.file || entity.id }, additions, textures, base?.uuid, createJemTransferProject({ model, name: entity.file || entity.id }, additions, textures, base?.uuid,
{ width: Project.texture_width, height: Project.texture_height }, index === 2); { width: Project.texture_width, height: Project.texture_height, projectWide: Format.id === 'geckolib_model' }, index === 2);
} catch (error) { } catch (error) {
busy = false; busy = false;
Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message }); Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message });
@@ -2834,7 +2894,15 @@
const eligible = autoplayStagesAction?.value && Format.id === 'geckolib_model' const eligible = autoplayStagesAction?.value && Format.id === 'geckolib_model'
&& Timeline.playing && animation?.length > 0 && animation.loop !== 'loop' && Timeline.playing && animation?.length > 0 && animation.loop !== 'loop'
&& !(Timeline.custom_range?.[0] || Timeline.custom_range?.[1]); && !(Timeline.custom_range?.[0] || Timeline.custom_range?.[1]);
const result = autoplayLoopOriginal.apply(this, args); const globalLoop = typeof BarItems !== 'undefined' ? BarItems.looped_animation_playback : null;
const savedLoop = globalLoop?.value;
let result;
try {
if (eligible && globalLoop) globalLoop.value = false;
result = autoplayLoopOriginal.apply(this, args);
} finally {
if (eligible && globalLoop) globalLoop.value = savedLoop;
}
if (eligible && autoplayStagesAction?.value && Animation.selected === animation && !Timeline.playing) { if (eligible && autoplayStagesAction?.value && Animation.selected === animation && !Timeline.playing) {
const next = nextAutoplayStage(animation); const next = nextAutoplayStage(animation);
if (next) { if (next) {
@@ -4013,7 +4081,7 @@
return true; return true;
} catch (e) { } catch (e) {
Blockbench.showMessageBox({ Blockbench.showMessageBox({
title: 'Create Animation Definition', title: 'Animation Definition',
message: e?.message || String(e), message: e?.message || String(e),
buttons: ['OK'], buttons: ['OK'],
}); });
@@ -4022,7 +4090,7 @@
}; };
const dialog = new Dialog({ const dialog = new Dialog({
id: 'create_afw_animation_definition_review', id: 'create_afw_animation_definition_review',
title: `Create Animation Definition: ${group.id}`, title: `Animation Definition: ${group.id}`,
width: 760, width: 760,
form, form,
buttons: ['Cancel', 'Save', 'Create Animdef'], buttons: ['Cancel', 'Save', 'Create Animdef'],
@@ -4045,7 +4113,7 @@
detected = collectConjoinedAnimationGroups(); detected = collectConjoinedAnimationGroups();
} catch (e) { } catch (e) {
Blockbench.showMessageBox({ Blockbench.showMessageBox({
title: 'Create Animation Definition', title: 'Animation Definition',
message: e?.message || String(e), message: e?.message || String(e),
buttons: ['OK'], buttons: ['OK'],
}); });
@@ -4053,7 +4121,7 @@
} }
if (!detected.groups.length) { if (!detected.groups.length) {
Blockbench.showMessageBox({ Blockbench.showMessageBox({
title: 'Create Animation Definition', title: 'Animation Definition',
message: 'No animations named <animationId>.p<number> were found in this project.', message: 'No animations named <animationId>.p<number> were found in this project.',
buttons: ['OK'], buttons: ['OK'],
}); });
@@ -4068,7 +4136,7 @@
const defaultId = selectedStage && options[selectedStage.id] ? selectedStage.id : detected.groups[0].id; const defaultId = selectedStage && options[selectedStage.id] ? selectedStage.id : detected.groups[0].id;
const dialog = new Dialog({ const dialog = new Dialog({
id: 'create_afw_animation_definition', id: 'create_afw_animation_definition',
title: 'Create Animation Definition', title: 'Animation Definition',
width: 620, width: 620,
form: { form: {
_info: { _info: {
@@ -4095,7 +4163,7 @@
return true; return true;
} catch (e) { } catch (e) {
Blockbench.showMessageBox({ Blockbench.showMessageBox({
title: 'Create Animation Definition', title: 'Animation Definition',
message: e?.message || String(e), message: e?.message || String(e),
buttons: ['OK'], buttons: ['OK'],
}); });
@@ -5571,11 +5639,44 @@
dialog.show(); dialog.show();
} }
function loadImportTextures(row, packs, io) { function createImportAssetLookup(io, paths) {
const opened = new Map(), resources = new Map();
return {
*sources() {
paths ||= discoverPropSources(io).map((entry) => entry.file);
for (const file of paths) {
let source = opened.get(file);
if (source === undefined) {
try { source = openPropSource(file, io); } catch (_) { source = null; }
opened.set(file, source);
if (opened.size > 2) opened.delete(opened.keys().next().value);
}
if (source) yield source;
}
},
read(relative) {
if (resources.has(relative)) return resources.get(relative);
for (const source of this.sources()) {
try {
const bytes = source.read(relative);
if (!bytes || bytes.length > 4 * 1024 * 1024 || !pngDimensions(bytes)) continue;
let metadata;
try { metadata = propJson(source, `${relative}.mcmeta`); } catch (_) { /* Static texture. */ }
const result = { bytes, relative, dimensions: importTextureDimensions(bytes, metadata) };
resources.set(relative, result);
return result;
} catch (_) { /* Try the next source. */ }
}
resources.set(relative, null);
return null;
},
};
}
function loadImportTextures(row, packs, io, assets = createImportAssetLookup(io)) {
const candidates = actorTextureCandidates(row.json, row.geo, row.resolved.file, row.entity, row.slim); const candidates = actorTextureCandidates(row.json, row.geo, row.resolved.file, row.entity, row.slim);
row.textures = new Map(); row.textures = new Map();
row.defaultTexture = undefined; row.defaultTexture = undefined;
let cachedSources;
let modelPack; let modelPack;
try { modelPack = findImportPack(row.resolved.file, io); } catch (_) { /* Standalone model. */ } try { modelPack = findImportPack(row.resolved.file, io); } catch (_) { /* Standalone model. */ }
const searchPacks = modelPack && !packs.some((pack) => pack.root === modelPack.root) const searchPacks = modelPack && !packs.some((pack) => pack.root === modelPack.root)
@@ -5595,26 +5696,10 @@
} catch (_) { /* Let Blockbench report unreadable texture files. */ } } catch (_) { /* Let Blockbench report unreadable texture files. */ }
return { ...texture, dimensions }; return { ...texture, dimensions };
} }
if (!cachedSources) {
cachedSources = [];
for (const entry of discoverPropSources(io)) {
try { cachedSources.push(openPropSource(entry.file, io)); } catch (_) { /* Try another asset source. */ }
}
}
const parsed = importResourceId(id); const parsed = importResourceId(id);
if (!parsed) return null; if (!parsed) return null;
const relative = `assets/${parsed.namespace}/${parsed.resource}`; const relative = `assets/${parsed.namespace}/${parsed.resource}`;
for (const source of cachedSources) { return assets.read(relative);
let bytes;
try { bytes = source.read(relative); } catch (_) { continue; }
if (!bytes || bytes.length > 4 * 1024 * 1024) continue;
let dimensions = pngDimensions(bytes);
if (!dimensions) continue;
try { dimensions = importTextureDimensions(bytes, propJson(source, `${relative}.mcmeta`)); }
catch (_) { /* Keep the PNG dimensions when metadata is invalid. */ }
return { bytes, relative, dimensions };
}
return null;
}; };
for (const resource of candidates) { for (const resource of candidates) {
const texture = resolveTexture(resource); const texture = resolveTexture(resource);
@@ -5690,7 +5775,7 @@
refreshBlockGuideFaces(); refreshBlockGuideFaces();
const finished = { ...aspects, elements: Outliner.elements.slice(), groups: Group.all.slice(), const finished = { ...aspects, elements: Outliner.elements.slice(), groups: Group.all.slice(),
textures: Texture.all.slice(), animations: Animation.all.slice() }; textures: Texture.all.slice(), animations: Animation.all.slice() };
Undo.finishEdit('Automatically import animation', finished); Undo.finishEdit('Import Whole Animation', finished);
Project.saved = false; Project.saved = false;
if (Animation.all[0]) Animation.all[0].select(); if (Animation.all[0]) Animation.all[0].select();
if (plan.propEntries) void refreshPropPreviews(true); if (plan.propEntries) void refreshPropPreviews(true);
@@ -5705,7 +5790,7 @@
async function automaticallyImportAnimation() { async function automaticallyImportAnimation() {
try { try {
const io = desktopFiles(); const io = desktopFiles();
const file = await pickImportFile('Automatically Import Animation (definition or animation file)'); const file = await pickImportFile('Import Whole Animation (definition or animation file)');
if (!file) return; if (!file) return;
const source = findImportPack(file.path, io); const source = findImportPack(file.path, io);
const companions = companionImportFiles(source, file.path, io); const companions = companionImportFiles(source, file.path, io);
@@ -5772,7 +5857,7 @@
form[`model_${index}`] = { type: 'file', label: `Actor ${index + 1} model override`, form[`model_${index}`] = { type: 'file', label: `Actor ${index + 1} model override`,
return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' }; return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' };
}); });
const choices = await importDialog('Automatic Import: Actors & Models', form, 'Create Project and Import', function(result) { const choices = await importDialog('Import Whole Animation: Actors & Models', form, 'Create Project and Import', function(result) {
this.form.setValues(Object.fromEntries(definition.actors.map((_, index) => this.form.setValues(Object.fromEntries(definition.actors.map((_, index) =>
[`source_${index}`, modelStatus(index, result)])), false); [`source_${index}`, modelStatus(index, result)])), false);
}); });
@@ -5814,6 +5899,7 @@
const prepared = prepareAnimationDefinitionImport(relabeled, analysis); const prepared = prepareAnimationDefinitionImport(relabeled, analysis);
const warnings = registry.warnings.slice(); const warnings = registry.warnings.slice();
if (ignoredLabels.length) warnings.push(`Ignored animation actors not mapped to the definition: ${ignoredLabels.join(', ')}.`); if (ignoredLabels.length) warnings.push(`Ignored animation actors not mapped to the definition: ${ignoredLabels.join(', ')}.`);
const assets = createImportAssetLookup(io);
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
const override = choices[`model_${index}`]; const override = choices[`model_${index}`];
row.resolved = override?.path ? { file: override.path, kind: 'Manually selected' } row.resolved = override?.path ? { file: override.path, kind: 'Manually selected' }
@@ -5821,7 +5907,7 @@
if (!row.resolved) throw new Error(`No model found for ${row.label}. Select a model override for actor ${index + 1} and try again.`); if (!row.resolved) throw new Error(`No model found for ${row.label}. Select a model override for actor ${index + 1} and try again.`);
row.json = readImportJson(row.resolved.file, io); row.json = readImportJson(row.resolved.file, io);
row.geo = validateImportGeometry(row.json); row.geo = validateImportGeometry(row.json);
loadImportTextures(row, registry.packs, io); loadImportTextures(row, registry.packs, io, assets);
const names = new Set(row.geo.bones.map((bone) => bone.name)); const names = new Set(row.geo.bones.map((bone) => bone.name));
const missing = new Set(parsed.stages.flatMap((stage) => stage.entries.filter((entry) => entry.actorLabel === row.label) const missing = new Set(parsed.stages.flatMap((stage) => stage.entries.filter((entry) => entry.actorLabel === row.label)
.flatMap((entry) => Object.keys(entry.animObj.bones || {}).filter((bone) => !names.has(bone))))); .flatMap((entry) => Object.keys(entry.animObj.bones || {}).filter((bone) => !names.has(bone)))));
@@ -5845,7 +5931,7 @@
Blockbench.showQuickMessage(`Imported with notes: ${warnings[0]}${more}`, 6000); Blockbench.showQuickMessage(`Imported with notes: ${warnings[0]}${more}`, 6000);
} }
} catch (error) { } catch (error) {
Blockbench.showMessageBox({ title: 'Automatically Import Animation', message: error.message, buttons: ['OK'] }); Blockbench.showMessageBox({ title: 'Import Whole Animation', message: error.message, buttons: ['OK'] });
} }
} }
@@ -6166,7 +6252,7 @@
function refreshSoundLibraryPanel() { function refreshSoundLibraryPanel() {
installFiguraSoundCompat(); installFiguraSoundCompat();
if (typeof document !== 'undefined') { if (typeof document !== 'undefined' && !Blockbench.isWeb) {
const panel = document.getElementById('panel_keyframe'); const panel = document.getElementById('panel_keyframe');
if (panel && !document.getElementById('animationdirector_sound_library_button')) { if (panel && !document.getElementById('animationdirector_sound_library_button')) {
const button = document.createElement('button'); const button = document.createElement('button');
@@ -6181,14 +6267,15 @@
Plugin.register(PLUGIN_ID, { Plugin.register(PLUGIN_ID, {
title: 'AnimationDirector', title: 'AnimationDirector',
author: 'L1Z0', author: 'L1Z0',
icon: 'groups', icon: 'sports_kabaddi',
description: 'Multi-actor animation in Blockbench without bone-name clashes.', description: 'Multi-actor animation in Blockbench without bone-name clashes.',
about: `AnimationDirector is a Blockbench plugin for the mod of the same name. about: `AnimationDirector is a Blockbench plugin for Needs of Nature.
It lets you import and animate multiple actors in one project without Blockbench auto-renaming bones when different models use the same bone names (like head, body, etc.). It does this by organizing each actor with an actorN_ prefix, so everything stays consistent. It lets you import and animate multiple actors in one project without Blockbench auto-renaming bones when different models use the same bone names (like head, body, etc.). It does this by organizing each actor with an actorN_ prefix, so everything stays consistent.
You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a configurable AFW animation definition from the current project.`, You can import or export complete AFW animation IDs as one file containing all stages and actors, or generate a configurable AFW animation definition from the current project.`,
version: '2.0.0', version: '2.0.0',
min_version: '5.2.1',
variant: 'both', variant: 'both',
onload() { onload() {
autoplayStagesAction = new Toggle('animationdirector_autoplay_stages', { autoplayStagesAction = new Toggle('animationdirector_autoplay_stages', {
@@ -6244,6 +6331,7 @@ You can import or export complete AFW animation IDs as one file containing all s
name: 'NoN Sound Library', icon: 'library_music', category: 'animation', name: 'NoN Sound Library', icon: 'library_music', category: 'animation',
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.', description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
click: showSoundLibrary, click: showSoundLibrary,
condition: () => !Blockbench.isWeb,
}); });
soundLibraryListener = refreshSoundLibraryPanel; soundLibraryListener = refreshSoundLibraryPanel;
Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener); Blockbench.on(SOUND_EVENTS.join(' '), soundLibraryListener);
@@ -6332,6 +6420,7 @@ You can import or export complete AFW animation IDs as one file containing all s
Blockbench.on('select_animation select_project load_project unselect_project undo redo render_frame', propListener); Blockbench.on('select_animation select_project load_project unselect_project undo redo render_frame', propListener);
propAction = new Action('animationdirector_item_props', { propAction = new Action('animationdirector_item_props', {
condition: () => !Blockbench.isWeb,
name: 'Item Props', icon: 'interests', description: 'Preview item props on actor bones and configure default/per-stage animdef props.', name: 'Item Props', icon: 'interests', description: 'Preview item props on actor bones and configure default/per-stage animdef props.',
click: showPropDialog, click: showPropDialog,
}); });
@@ -6385,6 +6474,7 @@ You can import or export complete AFW animation IDs as one file containing all s
click: exportGeckoModel, click: exportGeckoModel,
}); });
createJemModelAction = new Action('animationdirector_create_jem_model', { createJemModelAction = new Action('animationdirector_create_jem_model', {
condition: () => !Blockbench.isWeb,
name: 'Create JEM Model', icon: 'icon-format_optifine', name: 'Create JEM Model', icon: 'icon-format_optifine',
description: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.', description: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.',
click: showCreateJemModel, click: showCreateJemModel,
@@ -6399,7 +6489,7 @@ You can import or export complete AFW animation IDs as one file containing all s
}); });
createAnimationDefinitionAction = new Action('create_afw_animation_definition_stub', { createAnimationDefinitionAction = new Action('create_afw_animation_definition_stub', {
name: 'Create Animation Definition', name: 'Animation Definition',
description: 'Generate an AFW animdef from one <animationId>.p<number> animation group.', description: 'Generate an AFW animdef from one <animationId>.p<number> animation group.',
icon: 'post_add', icon: 'post_add',
click: showCreateAnimationDefinitionDialog, click: showCreateAnimationDefinitionDialog,
@@ -6411,11 +6501,13 @@ You can import or export complete AFW animation IDs as one file containing all s
click: importAnimationDefinition, click: importAnimationDefinition,
}); });
automaticImportAction = new Action('animationdirector_automatic_import', { automaticImportAction = new Action('animationdirector_automatic_import', {
name: 'Automatically Import Animation', icon: 'add_box', condition: () => !Blockbench.isWeb,
name: 'Import Whole Animation', icon: 'add_box',
description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.', description: 'Import an animation definition, actor models and all stages from extracted packs into a new project.',
click: automaticallyImportAnimation, click: automaticallyImportAnimation,
}); });
packFoldersAction = new Action('animationdirector_pack_folders', { packFoldersAction = new Action('animationdirector_pack_folders', {
condition: () => !Blockbench.isWeb,
name: 'Pack Folders', icon: 'folder', name: 'Pack Folders', icon: 'folder',
description: 'Remember extracted packs and their model/texture lookup priority for automatic import.', description: 'Remember extracted packs and their model/texture lookup priority for automatic import.',
click() { click() {
+38
View File
@@ -0,0 +1,38 @@
# AnimationDirector
Blockbench tools for creating multi-actor animations and animation definitions for Needs of Nature.
## Installation And Requirements
- Install `AnimationDirector.js` using Blockbench's **Plugins > Load Plugin from File**. No separate logo or asset files are required.
- Supported baseline: Blockbench **5.2.1 or newer**.
- Enable the **GeckoLib** format plugin to create/import multi-actor animation projects.
- **Create JEM Model** requires **CEM Template Loader**. Its preset loader may require an internet connection.
- Desktop Blockbench is required for automatic import, pack folders, item props, JEM transfer, and the NoN sound library. These actions are hidden in browser Blockbench; manual JSON import/export remains available.
- **Asset Browser** is optional. AnimationDirector can use its downloaded Minecraft asset cache, installed Minecraft client archives, or manually selected assets. It does not download vanilla textures itself.
## Workflow
1. Use **Tools > AnimationDirector Anims > Import Whole Animation** to select an animdef or `.animation.json` from an extracted pack. A new GeckoLib project is created after reviewing actors and models.
2. Alternatively, import actors and animations manually. Actor bones use `actorN_` prefixes to prevent name collisions.
3. Use **Actor Preferences** to set actor names, preview textures, and matching information.
4. Configure **Block Requirements** with live required-block/clearance previews, and **Item Props** with locally available assets or a PNG texture.
5. Use **Animation Definition** to save generator fields in the project or export an animdef. Imported definitions replace the saved fields for that animation.
6. Use **Export Animations** to export all physical stages and actor clips for an animation ID. Keep the Blockbench project as the editable source.
## Preview Tools
- **NoN Sound Library** auditions cues and assigns local sound previews to imported/created sound keyframes. These preview files are not exported as animation cues.
- **Autoplay Stages** advances non-looping clips to the next physical stage of the same animation ID. It takes priority over Blockbench's global loop toggle without changing that preference. Looping clips and custom timeline ranges remain unchanged.
- The timeline pose buttons copy the preceding stage's final pose to the current start, or copy keyed channels from the current start to its end.
- Mixed texture-size UV normalization is applied only to GeckoLib projects. Other formats retain their native UV sizing.
- **Tools > AnimationDirector Model > Create JEM Model** transfers selected additions into a CEM preset. It does not transfer animation keyframes, locators, or modifications to existing vanilla cubes. Review the resulting model and save its textures alongside the exported JEM as appropriate.
## Checks
```sh
node --check AnimationDirector.js
node -e "for(const f of require('node:fs').readdirSync('test').filter(f=>f.endsWith('.test.js')))require('./test/'+f)"
```
The tests use Blockbench API doubles. Before release, also check real Blockbench imports, Undo/Redo, JEM texture export, mixed atlas sizes, sound playback, and plugin unload/reload.
+39 -1
View File
@@ -12,7 +12,8 @@ function harness(capture = false) {
const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', ` const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', `
globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry, globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry,
resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles, resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles,
validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport, resolveDefinitionImportStages }; validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport, resolveDefinitionImportStages,
createImportAssetLookup, importGeometryIntoCurrentProject };
Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {', Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {',
` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`); ` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`);
const storage = new Map(), dialogs = [], errors = [], undo = [], messages = []; const storage = new Map(), dialogs = [], errors = [], undo = [], messages = [];
@@ -38,6 +39,7 @@ function harness(capture = false) {
Formats: { geckolib_model: { id: 'geckolib_model' } }, Formats: { geckolib_model: { id: 'geckolib_model' } },
newProject(format) { newProject(format) {
assert.equal(format.id, 'geckolib_model'); assert.equal(format.id, 'geckolib_model');
context.Format = format;
context.Project = { uuid: 'new-project' }; context.Project = { uuid: 'new-project' };
return true; return true;
}, },
@@ -530,3 +532,39 @@ test('unexpected animation-load failures cancel the batch rather than keeping a
definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/); definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/);
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']); assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
}); });
test('manual replacement rejects duplicate and cyclic bones before touching the old actor', () => {
const h = harness(), original = new h.context.Group({ name: 'actor1_body' }); original.init();
for (const bones of [[{ name: 'body' }, { name: 'body' }], [{ name: 'body', parent: 'head' }, { name: 'head', parent: 'body' }]]) {
assert.throws(() => h.importGeometryIntoCurrentProject({ bones }, 1, true), /unique|Cyclic/);
assert.equal(h.context.Group.all[0], original);
assert.equal(h.undo.length, 0);
}
});
test('manual geometry import cancels its undo transaction when model creation fails', () => {
const h = harness();
h.context.Group.prototype.init = () => { throw new Error('model creation failed'); };
assert.throws(() => h.importGeometryIntoCurrentProject({ bones: [{ name: 'body' }] }, 1), /model creation failed/);
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
});
test('import asset lookup opens sources lazily and reuses positive and negative results', (t) => {
const f = fixtures(t), h = harness(), first = f.makePack('first'), second = f.makePack('second');
const relative = 'assets/test/textures/demo.png', png = Buffer.alloc(24);
Buffer.from('89504e470d0a1a0a', 'hex').copy(png); png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20);
const file = path.join(first.root, relative); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, png);
let secondOpened = 0, textureReads = 0;
const counted = { path, fs: { ...fs, statSync(file, ...args) {
if (file === second.root) secondOpened++;
return fs.statSync(file, ...args);
}, readFileSync(file, ...args) { if (file.endsWith('demo.png')) textureReads++; return fs.readFileSync(file, ...args); } } };
const assets = h.createImportAssetLookup(counted, [first.root, second.root]);
assert.equal(assets.read(relative).dimensions.width, 32);
assert.equal(secondOpened, 0);
assets.read(relative); assert.equal(textureReads, 1);
assert.equal(assets.read('assets/test/textures/missing.png'), null);
const opened = secondOpened;
assert.equal(assets.read('assets/test/textures/missing.png'), null);
assert.equal(secondOpened, opened);
});
+48 -3
View File
@@ -82,7 +82,7 @@ function harness() {
finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); }, finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); },
}, },
Dialog: class { constructor(options) { dialogs.push(options); } show() {} }, Dialog: class { constructor(options) { dialogs.push(options); } show() {} },
Blockbench: { export(options, callback) { exports.push(JSON.parse(options.content)); callback(); }, Blockbench: { isWeb: false, export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); }, on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); },
addCSS() { return { delete() {} }; }, addCSS() { return { delete() {} }; },
showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } }, showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } },
@@ -103,15 +103,60 @@ function importAnalysis() {
{ stageNumber: 2, name: 'test.p2', loop: false, cycleSeconds: 2 }], warnings: [] }; { stageNumber: 2, name: 'test.p2', loop: false, cycleSeconds: 2 }], warnings: [] };
} }
test('imported beds and fences receive their editor-only preview dimensions', () => {
const cases = [
['center_support', '#minecraft:beds', 0.5625],
['center_support', 'minecraft:red_bed', 0.5625],
['center_support', ['minecraft:white_bed', 'minecraft:blue_bed'], 0.5625],
['wall', '#minecraft:fences', 0.25],
['wall', 'minecraft:oak_fence', 0.25],
['wall', 'minecraft:nether_brick_fence', 0.25],
['wall', 'minecraft:oak_fence_gate', 1],
['wall', ['minecraft:oak_fence', 'minecraft:stone'], 1],
];
for (const [type, blocks, expected] of cases) {
const h = harness();
const requirement = type === 'wall' ? { type, blocks, height: { min: 1 }, width: { min: 1 } }
: { type, blocks, support: 'surface', placement: 'surface_footprint' };
const source = { actors: [{ label: 'player' }, { label: 'wolf' }], stages: [{ stage: 1 }, { stage: 2 }],
block_requirements: requirement };
h.project.animationdirector_block_requirements = JSON.stringify({ test: {
requirement: { type: 'wall', blocks: 'minecraft:stone' }, preview: { surfaceHeight: 1.5, wallThickness: 0.75 } } });
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source);
const entry = h.getAuthoredBlockRequirement('test');
assert.equal(type === 'wall' ? entry.preview.wallThickness : entry.preview.surfaceHeight, expected);
assert.equal(h.getSavedAnimdef('test').block_requirements.preview, undefined);
if (type === 'center_support') {
const surface = h.buildBlockRequirementGuideBoxes(entry).find((box) => box.kind === 'required');
assert.ok(surface);
}
}
});
test('desktop-only tools are visible in desktop Blockbench and hidden in browser Blockbench', () => {
const h = harness(); h.context.registration.onload();
const actions = h.menus.flatMap((menu) => menu.children).filter((action) => action && typeof action !== 'string');
const ids = ['animationdirector_automatic_import', 'animationdirector_pack_folders', 'animationdirector_item_props',
'animationdirector_sound_library', 'animationdirector_create_jem_model'];
for (const id of ids) {
const action = actions.find((entry) => entry.id === id);
assert.ok(action, `${id} is registered`);
assert.equal(action.condition(), true, `${id} is visible on desktop`);
h.context.Blockbench.isWeb = true;
assert.equal(action.condition(), false, `${id} is hidden in browsers`);
h.context.Blockbench.isWeb = false;
}
});
test('animation menu orders import and creation actions and has no deprecated split-file tools', () => { test('animation menu orders import and creation actions and has no deprecated split-file tools', () => {
const h = harness(); const h = harness();
h.context.registration.onload(); h.context.registration.onload();
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims'); const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [ assert.deepEqual(Array.from(menu.children, (action) => action === '_' ? '_' : action.name), [
'Automatically Import Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders', 'Import Whole Animation', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
'_', 'Actor Preferences', 'Replace Actor Model', '_', 'Actor Preferences', 'Replace Actor Model',
'_', 'Block Requirements', 'Item Props', 'NoN Sound Library', '_', 'Block Requirements', 'Item Props', 'NoN Sound Library',
'_', 'Create Animation Definition', 'Export Animations', '_', 'Animation Definition', 'Export Animations',
]); ]);
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8'); const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/); assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
+69 -2
View File
@@ -5,12 +5,36 @@ const path = require('node:path');
const vm = require('node:vm'); const vm = require('node:vm');
function harness() { function harness() {
// These tests exercise Z-axis rotations; Blockbench supplies full Three.js at runtime.
class Quaternion {
constructor() { this.angle = 0; }
setFromEuler(euler) { this.angle = euler.z; return this; }
multiply(other) { this.angle += other.angle; return this; }
invert() { this.angle = -this.angle; return this; }
toArray() { return [0, 0, Math.sin(this.angle / 2), Math.cos(this.angle / 2)]; }
fromArray(values) { this.angle = 2 * Math.atan2(values[2], values[3]); return this; }
}
class Euler {
constructor(x = 0, y = 0, z = 0) { Object.assign(this, { x, y, z }); }
setFromQuaternion(q) { this.x = this.y = 0; this.z = q.angle; return this; }
}
class Vector3 {
constructor(...values) { this.values = values; }
sub(other) { this.values = this.values.map((v, i) => v - other.values[i]); return this; }
add(other) { this.values = this.values.map((v, i) => v + other.values[i]); return this; }
applyQuaternion(q) {
const [x, y, z] = this.values, c = Math.cos(q.angle), s = Math.sin(q.angle);
this.values = [x * c - y * s, x * s + y * c, z]; return this;
}
toArray() { return this.values; }
}
class Group { class Group {
constructor(name, parent) { Object.assign(this, { name, origin: [1, 2, 3], rotation: [0, 0, 0], children: [], parent }); parent?.children.push(this); } constructor(name, parent) { Object.assign(this, { name, origin: [1, 2, 3], rotation: [0, 0, 0], children: [], parent }); parent?.children.push(this); }
} }
const h = { Group, Cube: class {}, Plugin: { register() {} } }; const h = { Group, Cube: class {}, THREE: { Quaternion, Euler, Vector3 },
Format: { id: 'geckolib_model', euler_order: 'ZYX' }, Plugin: { register() {} } };
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {', const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(' Plugin.register(PLUGIN_ID, {',
' globalThis.api = { cemTemplateCatalog, jemPresetParts, jemTransferSuggestions, validateJemChoices, jemAdditionBones, addJemEnergyVisibility, refreshJemEnergyFields };\n Plugin.register(PLUGIN_ID, {'); ' globalThis.api = { cemTemplateCatalog, jemPresetParts, jemTransferSuggestions, validateJemChoices, jemAdditionBones, addJemEnergyVisibility, refreshJemEnergyFields, jemTransferTransform, createJemTransferProject };\n Plugin.register(PLUGIN_ID, {');
vm.runInNewContext(source, h); vm.runInNewContext(source, h);
return h; return h;
} }
@@ -21,6 +45,49 @@ test('JEM preset parent paths include attachment roots and nested groups', () =>
assert.deepEqual(Array.from(parts, (part) => [part.name, part.path]), [['body', '0'], ['fur', '0/0'], ['tip', '0/0/0'], ['head', '1']]); assert.deepEqual(Array.from(parts, (part) => [part.name, part.path]), [['body', '0'], ['fur', '0/0'], ['tip', '0/0/0'], ['head', '1']]);
}); });
test('JEM transfer compensates different parent rotations without rotating children twice', () => {
const h = harness(), body = new h.Group('actor1_body'), extra = new h.Group('actor1_extra', body);
body.rotation = [0, 0, 90]; extra.origin = [2, 2, 3]; extra.rotation = [0, 0, 10];
const suggestions = h.api.jemTransferSuggestions([body, extra], [{ name: 'body', path: '0' }], 1);
const addition = h.api.validateJemChoices(suggestions, { include_0: true, parent_0: '0' })[0];
const target = new h.Group('body'); target.origin = [10, 20, 30];
const transform = h.api.jemTransferTransform(addition, target);
transform.offset.forEach((value, axis) => assert.ok(Math.abs(value - [8, 19, 27][axis]) < 1e-8));
assert.ok(Math.abs(transform.rotation[2] - 100) < 1e-8);
assert.deepEqual(Array.from(addition.tree.rotation), [0, 0, 10]);
});
test('JEM creation rescales GeckoLib UVs but preserves per-texture UVs in other formats', () => {
for (const projectWide of [false, true]) {
const h = harness(), cubes = [];
h.Group.all = [];
h.Group.prototype.init = function() { h.Group.all.push(this); return this; };
h.Group.prototype.addTo = function(parent) { this.parent = parent; parent.children.push(this); return this; };
h.Cube = class {
constructor(data) { Object.assign(this, data); cubes.push(this); }
addTo(parent) { parent.children.push(this); return this; }
init() { return this; }
};
h.Texture = class {
constructor(data) { Object.assign(this, data, { uuid: 'texture' }); }
fromDataURL() { return this; }
add() { return this; }
};
h.Project = {};
h.Formats = { optifine_entity: { codec: { parse() { new h.Group('body').init(); } } } };
h.newProject = () => true;
h.Canvas = { updateAll() {} }; h.Blockbench = { showQuickMessage() {} };
const addition = { target: '0', parentOrigin: [0, 0, 0], parentRotation: [0, 0, 0, 1], rotationOrder: 'ZYX',
tree: { name: 'extra', origin: [0, 0, 0], rotation: [0, 0, 0], children: [{ cube: {
name: 'cube', from: [0, 0, 0], to: [1, 1, 1], box_uv: false,
faces: { north: { texture: 'old', uv: [0, 0, 16, 16] } } } }] } };
h.api.createJemTransferProject({ name: 'test', model: { models: [] } }, [addition],
[{ id: 'old', data: { uv_width: 32, uv_height: 32 }, image: 'mock' }], 'old',
{ width: 64, height: 64, projectWide }, false);
assert.deepEqual(Array.from(cubes[0].faces.north.uv), projectWide ? [0, 0, 8, 8] : [0, 0, 16, 16]);
}
});
test('suggests added subtrees once, strips actor prefixes and leaves vanilla hierarchy unchanged', () => { test('suggests added subtrees once, strips actor prefixes and leaves vanilla hierarchy unchanged', () => {
const h = harness(), root = new h.Group('actor1_root'), body = new h.Group('actor1_body', root); const h = harness(), root = new h.Group('actor1_root'), body = new h.Group('actor1_body', root);
const d = new h.Group('actor1_d', body), tip = new h.Group('actor1_tip', d); const d = new h.Group('actor1_d', body), tip = new h.Group('actor1_tip', d);
+16
View File
@@ -48,3 +48,19 @@ test('manual playback stops do not advance and unload restores playback', () =>
const wrapper = h.Timeline.loop; h.api.removeStageAutoplay(); const wrapper = h.Timeline.loop; h.api.removeStageAutoplay();
assert.notEqual(h.Timeline.loop, wrapper); assert.notEqual(h.Timeline.loop, wrapper);
}); });
test('autoplay overrides global looping for once stages and restores it even on failure', () => {
const h = harness(); h.api.removeStageAutoplay();
h.BarItems = { looped_animation_playback: { value: true } };
h.Timeline.loop = function() { if (!h.BarItems.looped_animation_playback.value) this.playing = false; };
const first = h.clip('example.p1'), next = h.clip('example.p2');
h.Animation.selected = first; h.Animation.all = [first, next];
h.api.installStageAutoplay(); h.Timeline.loop();
assert.equal(h.Animation.selected, next);
assert.equal(h.BarItems.looped_animation_playback.value, true);
h.api.removeStageAutoplay();
h.Timeline.loop = () => { throw new Error('preview failed'); };
h.api.installStageAutoplay();
assert.throws(() => h.Timeline.loop(), /preview failed/);
assert.equal(h.BarItems.looped_animation_playback.value, true);
});