tweaks
This commit is contained in:
+252
-160
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,8 @@ function harness(capture = false) {
|
||||
const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', `
|
||||
globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry,
|
||||
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) {',
|
||||
` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`);
|
||||
const storage = new Map(), dialogs = [], errors = [], undo = [], messages = [];
|
||||
@@ -38,6 +39,7 @@ function harness(capture = false) {
|
||||
Formats: { geckolib_model: { id: 'geckolib_model' } },
|
||||
newProject(format) {
|
||||
assert.equal(format.id, 'geckolib_model');
|
||||
context.Format = format;
|
||||
context.Project = { uuid: 'new-project' };
|
||||
return true;
|
||||
},
|
||||
@@ -530,3 +532,39 @@ test('unexpected animation-load failures cancel the batch rather than keeping a
|
||||
definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ function harness() {
|
||||
finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); },
|
||||
},
|
||||
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); },
|
||||
addCSS() { return { delete() {} }; },
|
||||
showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } },
|
||||
@@ -103,15 +103,60 @@ function importAnalysis() {
|
||||
{ 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', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
|
||||
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',
|
||||
'_', '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');
|
||||
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
|
||||
|
||||
@@ -5,12 +5,36 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
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 {
|
||||
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, {',
|
||||
' 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);
|
||||
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']]);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
|
||||
@@ -48,3 +48,19 @@ test('manual playback stops do not advance and unload restores playback', () =>
|
||||
const wrapper = h.Timeline.loop; h.api.removeStageAutoplay();
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user