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
+252 -160
View File
@@ -936,9 +936,21 @@
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) {
const entry = getAuthoredBlockRequirement(animationId);
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 center = (result) => result?.type === 'center_support';
const directional = (result) => wall(result) || result?.placement !== 'surface_footprint';
@@ -949,7 +961,7 @@
const form = {
_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 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' } },
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.' },
@@ -980,9 +992,9 @@
clearance_height: dimension('Clearance height', req.clearance?.height ?? 2, 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.' },
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.' },
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.' },
};
return form;
@@ -1207,11 +1219,8 @@
}
function importGeometryIntoCurrentProject(geo, actorIndex, replaceExisting = false, manageUndo = true) {
const bones = Array.isArray(geo?.bones) ? geo.bones : [];
if (!bones.length) {
Blockbench.showQuickMessage('No bones found in this geometry JSON.', 2500);
return;
}
validateImportGeometry({ 'minecraft:geometry': [geo] });
const bones = geo.bones;
const prefix = `actor${actorIndex}_`;
const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null;
@@ -1219,116 +1228,125 @@
if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: Cube.all.slice(), textures: [],
uv_mode: true, actor_uv_edit: true,
animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] });
if (replaceExisting) removeActorGeometry(actorIndex);
const uvWidth = validUvDimension(geo?.description?.texture_width);
const uvHeight = validUvDimension(geo?.description?.texture_height);
expandProjectUvs(uvWidth, uvHeight);
let editing = manageUndo;
try {
if (replaceExisting) removeActorGeometry(actorIndex);
const uvWidth = validUvDimension(geo?.description?.texture_width);
const uvHeight = validUvDimension(geo?.description?.texture_height);
expandProjectUvs(uvWidth, uvHeight);
const boneByName = new Map();
for (const b of bones) if (b?.name) boneByName.set(b.name, b);
const boneByName = new Map();
for (const b of bones) if (b?.name) boneByName.set(b.name, b);
const groupByBoneName = new Map();
for (const boneName of boneByName.keys()) {
const g = new Group({ name: prefix + boneName });
g.init();
groupByBoneName.set(boneName, g);
}
// parenting
for (const [boneName, b] of boneByName) {
const g = groupByBoneName.get(boneName);
const parentGroup = b?.parent ? groupByBoneName.get(b.parent) : null;
g.addTo(parentGroup || 'root');
}
// pivots + rotations
for (const [boneName, b] of boneByName) {
const g = groupByBoneName.get(boneName);
g.origin = convertPos(v3(b.pivot, 0));
g.rotation = convertRot(v3(b.rotation, 0));
const locators = b?.locators && typeof b.locators === 'object' && !Array.isArray(b.locators)
? b.locators : {};
for (const [locatorName, locatorData] of Object.entries(locators)) {
const position = Array.isArray(locatorData)
? locatorData
: locatorData?.offset || locatorData?.position || locatorData?.from;
if (!Array.isArray(position)) continue;
const locator = new Locator({
name: `${prefix}${locatorName}`,
from: convertPos(v3(position, 0)),
});
locator.init();
locator.addTo(g);
const groupByBoneName = new Map();
for (const boneName of boneByName.keys()) {
const g = new Group({ name: prefix + boneName });
g.init();
groupByBoneName.set(boneName, g);
}
}
// cubes
for (const [boneName, b] of boneByName) {
const g = groupByBoneName.get(boneName);
const cubes = Array.isArray(b?.cubes) ? b.cubes : [];
// parenting
for (const [boneName, b] of boneByName) {
const g = groupByBoneName.get(boneName);
const parentGroup = b?.parent ? groupByBoneName.get(b.parent) : null;
g.addTo(parentGroup || 'root');
}
for (let i = 0; i < cubes.length; i++) {
const c = cubes[i];
const origin = v3(c?.origin, 0);
const size = v3(c?.size, 0);
// pivots + rotations
for (const [boneName, b] of boneByName) {
const g = groupByBoneName.get(boneName);
g.origin = convertPos(v3(b.pivot, 0));
g.rotation = convertRot(v3(b.rotation, 0));
const p1 = convertPos(origin);
const p2 = convertPos([origin[0] + size[0], origin[1] + size[1], origin[2] + size[2]]);
const locators = b?.locators && typeof b.locators === 'object' && !Array.isArray(b.locators)
? b.locators : {};
for (const [locatorName, locatorData] of Object.entries(locators)) {
const position = Array.isArray(locatorData)
? locatorData
: locatorData?.offset || locatorData?.position || locatorData?.from;
if (!Array.isArray(position)) continue;
const from = [Math.min(p1[0], p2[0]), Math.min(p1[1], p2[1]), Math.min(p1[2], p2[2])];
const to = [Math.max(p1[0], p2[0]), Math.max(p1[1], p2[1]), Math.max(p1[2], p2[2])];
const cube = new Cube({
name: `${prefix}${boneName}_cube${i + 1}`,
from,
to,
inflate: num(c?.inflate, 0),
});
cube.init();
cube.addTo(g);
if (c?.pivot) cube.origin = convertPos(v3(c.pivot, 0));
if (c?.rotation) cube.rotation = convertRot(v3(c.rotation, 0));
if (Array.isArray(c?.uv)) {
cube.box_uv = true;
cube.uv_offset = [num(c.uv[0], 0), num(c.uv[1], 0)];
cube.preview_controller?.updateUV?.(cube);
} else if (c?.uv && typeof c.uv === 'object') {
cube.box_uv = false;
applyPerFaceUVWithXFlip(cube, c.uv);
const locator = new Locator({
name: `${prefix}${locatorName}`,
from: convertPos(v3(position, 0)),
});
locator.init();
locator.addTo(g);
}
}
}
normalizeActorUvs(actorIndex, uvWidth, uvHeight, {
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) });
// cubes
for (const [boneName, b] of boneByName) {
const g = groupByBoneName.get(boneName);
const cubes = Array.isArray(b?.cubes) ? b.cubes : [];
const animationResult = replaceExisting
? rebindActorAnimations(actorIndex, previousBindings)
: { rebound: 0, missing: 0 };
for (let i = 0; i < cubes.length; i++) {
const c = cubes[i];
const origin = v3(c?.origin, 0);
const size = v3(c?.size, 0);
if (manageUndo) {
Canvas.updateAll();
applyAllActorTexturePreviews(false);
}
if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select();
if (manageUndo) Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`);
const p1 = convertPos(origin);
const p2 = convertPos([origin[0] + size[0], origin[1] + size[1], origin[2] + size[2]]);
const animationNotice = replaceExisting && animationResult.rebound
? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}`
: '';
if (manageUndo) Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000);
const from = [Math.min(p1[0], p2[0]), Math.min(p1[1], p2[1]), Math.min(p1[2], p2[2])];
const to = [Math.max(p1[0], p2[0]), Math.max(p1[1], p2[1]), Math.max(p1[2], p2[2])];
if (replaceExisting && animationResult.missing) {
Blockbench.showMessageBox({
title: 'Replacement Model Missing Animated Bones',
message: `${animationResult.missing} existing animation channel${animationResult.missing === 1 ? '' : 's'} could not be rebound because the replacement model does not contain the matching actor bone. Those channels were left unchanged.`,
buttons: ['OK'],
});
const cube = new Cube({
name: `${prefix}${boneName}_cube${i + 1}`,
from,
to,
inflate: num(c?.inflate, 0),
});
cube.init();
cube.addTo(g);
if (c?.pivot) cube.origin = convertPos(v3(c.pivot, 0));
if (c?.rotation) cube.rotation = convertRot(v3(c.rotation, 0));
if (Array.isArray(c?.uv)) {
cube.box_uv = true;
cube.uv_offset = [num(c.uv[0], 0), num(c.uv[1], 0)];
cube.preview_controller?.updateUV?.(cube);
} else if (c?.uv && typeof c.uv === 'object') {
cube.box_uv = false;
applyPerFaceUVWithXFlip(cube, c.uv);
}
}
}
normalizeActorUvs(actorIndex, uvWidth, uvHeight, {
width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) });
const animationResult = replaceExisting
? rebindActorAnimations(actorIndex, previousBindings)
: { rebound: 0, missing: 0 };
if (manageUndo) {
Canvas.updateAll();
applyAllActorTexturePreviews(false);
}
if (replaceExisting && typeof Animation !== 'undefined' && Animation.selected) Animation.selected.select();
if (manageUndo) {
Undo.finishEdit(`${replaceExisting ? 'Replace' : 'Import'} Actor ${actorIndex}`);
editing = false;
}
const animationNotice = replaceExisting && animationResult.rebound
? `, rebound ${animationResult.rebound} animation channel${animationResult.rebound === 1 ? '' : 's'}`
: '';
if (manageUndo) Blockbench.showQuickMessage(`${replaceExisting ? 'Replaced' : 'Imported'} actor with prefix "${prefix}"${animationNotice}`, 3000);
if (replaceExisting && animationResult.missing) {
Blockbench.showMessageBox({
title: 'Replacement Model Missing Animated Bones',
message: `${animationResult.missing} existing animation channel${animationResult.missing === 1 ? '' : 's'} could not be rebound because the replacement model does not contain the matching actor bone. Those channels were left unchanged.`,
buttons: ['OK'],
});
}
} catch (error) {
if (editing) Undo.cancelEdit();
throw error;
}
}
@@ -1382,12 +1400,9 @@
try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ }
try { sourcePaths.push(...savedPackFolders()); } catch (_) { /* No registered packs. */ }
try { sourcePaths.push(...discoverPropSources(io).map((source) => source.file)); } catch (_) { /* No cached assets. */ }
const sources = [];
for (const sourcePath of [...new Set(sourcePaths)]) {
try { sources.push(openPropSource(sourcePath, io)); } catch (_) { /* Try other asset sources. */ }
}
const sources = createImportAssetLookup(io, [...new Set(sourcePaths)]);
for (const id of candidateIds) {
for (const source of sources) {
for (const source of sources.sources()) {
const parsed = importResourceId(id);
const relative = `assets/${parsed.namespace}/${parsed.resource}`;
let bytes;
@@ -1880,28 +1895,33 @@
const actorIndex = readActorIndexFromName(cube.name) || readActorIndexFromName(cube.parent?.name);
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;
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 textureId = texture.uuid || texture.id || texture.name;
let facesChanged = false;
if (textureId && cube.faces) {
for (const face of faces) {
const cubeFace = cube.faces[face];
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.
// So after updating the normal face data, force the actual Three.js mesh material for preview only.
cube.preview_controller?.updateFaces?.(cube);
cube.preview_controller?.updateUV?.(cube);
cube.preview_controller?.updateGeometry?.(cube);
if (facesChanged || uvChanged) {
cube.preview_controller?.updateFaces?.(cube);
cube.preview_controller?.updateUV?.(cube);
cube.preview_controller?.updateGeometry?.(cube);
}
forceActorTextureMaterial(cube, texture);
// 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) {
@@ -2016,11 +2036,11 @@
clearScheduledActorTexturePreviewApplies();
if (!Project || !hasProjectActorTexturePreviews()) return;
// Project loading can rebuild preview meshes and load textures after project events fire.
// Reapply for a short window so the saved actor texture preview wins over the selected texture.
for (const delay of [0, 50, 250, 1000, 2500, 5000, 10000]) {
const projectId = Project.uuid;
// Texture load callbacks and project events schedule their own refreshes.
for (const delay of [0, 250]) {
const timeout = setTimeout(() => {
if (Project && hasProjectActorTexturePreviews()) applyAllActorTexturePreviews(false);
if (Project?.uuid === projectId && hasProjectActorTexturePreviews()) applyAllActorTexturePreviews(false);
}, delay);
actorTexturePreviewTimeouts.push(timeout);
}
@@ -2394,7 +2414,36 @@
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])] };
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) {
@@ -2477,10 +2526,18 @@
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]);
// 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 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) {
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);
if (tree.energyVisibility) addJemEnergyVisibility(group);
for (const child of tree.children) {
@@ -2490,6 +2547,7 @@
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);
if (data.rotation) data.rotation = convertedRotation(data.rotation);
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.
@@ -2502,7 +2560,9 @@
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));
if (sourceUv.projectWide) {
face.uv = face.uv.map((value, axis) => value * (axis % 2 ? texture.uv_height / sourceUv.height : texture.uv_width / sourceUv.width));
}
}
}
}
@@ -2574,7 +2634,7 @@
});
busy = true;
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) {
busy = false;
Blockbench.showMessageBox({ title: 'Create JEM Model', message: error.message });
@@ -2834,7 +2894,15 @@
const eligible = autoplayStagesAction?.value && Format.id === 'geckolib_model'
&& Timeline.playing && animation?.length > 0 && animation.loop !== 'loop'
&& !(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) {
const next = nextAutoplayStage(animation);
if (next) {
@@ -4013,7 +4081,7 @@
return true;
} catch (e) {
Blockbench.showMessageBox({
title: 'Create Animation Definition',
title: 'Animation Definition',
message: e?.message || String(e),
buttons: ['OK'],
});
@@ -4022,7 +4090,7 @@
};
const dialog = new Dialog({
id: 'create_afw_animation_definition_review',
title: `Create Animation Definition: ${group.id}`,
title: `Animation Definition: ${group.id}`,
width: 760,
form,
buttons: ['Cancel', 'Save', 'Create Animdef'],
@@ -4045,7 +4113,7 @@
detected = collectConjoinedAnimationGroups();
} catch (e) {
Blockbench.showMessageBox({
title: 'Create Animation Definition',
title: 'Animation Definition',
message: e?.message || String(e),
buttons: ['OK'],
});
@@ -4053,7 +4121,7 @@
}
if (!detected.groups.length) {
Blockbench.showMessageBox({
title: 'Create Animation Definition',
title: 'Animation Definition',
message: 'No animations named <animationId>.p<number> were found in this project.',
buttons: ['OK'],
});
@@ -4068,7 +4136,7 @@
const defaultId = selectedStage && options[selectedStage.id] ? selectedStage.id : detected.groups[0].id;
const dialog = new Dialog({
id: 'create_afw_animation_definition',
title: 'Create Animation Definition',
title: 'Animation Definition',
width: 620,
form: {
_info: {
@@ -4095,7 +4163,7 @@
return true;
} catch (e) {
Blockbench.showMessageBox({
title: 'Create Animation Definition',
title: 'Animation Definition',
message: e?.message || String(e),
buttons: ['OK'],
});
@@ -5571,11 +5639,44 @@
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);
row.textures = new Map();
row.defaultTexture = undefined;
let cachedSources;
let modelPack;
try { modelPack = findImportPack(row.resolved.file, io); } catch (_) { /* Standalone model. */ }
const searchPacks = modelPack && !packs.some((pack) => pack.root === modelPack.root)
@@ -5595,26 +5696,10 @@
} catch (_) { /* Let Blockbench report unreadable texture files. */ }
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);
if (!parsed) return null;
const relative = `assets/${parsed.namespace}/${parsed.resource}`;
for (const source of cachedSources) {
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;
return assets.read(relative);
};
for (const resource of candidates) {
const texture = resolveTexture(resource);
@@ -5690,7 +5775,7 @@
refreshBlockGuideFaces();
const finished = { ...aspects, elements: Outliner.elements.slice(), groups: Group.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;
if (Animation.all[0]) Animation.all[0].select();
if (plan.propEntries) void refreshPropPreviews(true);
@@ -5705,7 +5790,7 @@
async function automaticallyImportAnimation() {
try {
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;
const source = findImportPack(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`,
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) =>
[`source_${index}`, modelStatus(index, result)])), false);
});
@@ -5814,6 +5899,7 @@
const prepared = prepareAnimationDefinitionImport(relabeled, analysis);
const warnings = registry.warnings.slice();
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()) {
const override = choices[`model_${index}`];
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.`);
row.json = readImportJson(row.resolved.file, io);
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 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)))));
@@ -5845,7 +5931,7 @@
Blockbench.showQuickMessage(`Imported with notes: ${warnings[0]}${more}`, 6000);
}
} 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() {
installFiguraSoundCompat();
if (typeof document !== 'undefined') {
if (typeof document !== 'undefined' && !Blockbench.isWeb) {
const panel = document.getElementById('panel_keyframe');
if (panel && !document.getElementById('animationdirector_sound_library_button')) {
const button = document.createElement('button');
@@ -6181,14 +6267,15 @@
Plugin.register(PLUGIN_ID, {
title: 'AnimationDirector',
author: 'L1Z0',
icon: 'groups',
icon: 'sports_kabaddi',
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.
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',
min_version: '5.2.1',
variant: 'both',
onload() {
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',
description: 'Browse NoN and pack sounds, audition audio and add sound cues to the timeline.',
click: showSoundLibrary,
condition: () => !Blockbench.isWeb,
});
soundLibraryListener = refreshSoundLibraryPanel;
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);
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.',
click: showPropDialog,
});
@@ -6385,6 +6474,7 @@ You can import or export complete AFW animation IDs as one file containing all s
click: exportGeckoModel,
});
createJemModelAction = new Action('animationdirector_create_jem_model', {
condition: () => !Blockbench.isWeb,
name: 'Create JEM Model', icon: 'icon-format_optifine',
description: 'Transfer selected added bones into a vanilla CEM preset. Requires CEM Template Loader.',
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', {
name: 'Create Animation Definition',
name: 'Animation Definition',
description: 'Generate an AFW animdef from one <animationId>.p<number> animation group.',
icon: 'post_add',
click: showCreateAnimationDefinitionDialog,
@@ -6411,11 +6501,13 @@ You can import or export complete AFW animation IDs as one file containing all s
click: importAnimationDefinition,
});
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.',
click: automaticallyImportAnimation,
});
packFoldersAction = new Action('animationdirector_pack_folders', {
condition: () => !Blockbench.isWeb,
name: 'Pack Folders', icon: 'folder',
description: 'Remember extracted packs and their model/texture lookup priority for automatic import.',
click() {