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() {
|
||||
|
||||
Reference in New Issue
Block a user