This commit is contained in:
2026-09-28 22:39:00 +02:00
parent 8d1346986b
commit 733b62194a
4 changed files with 867 additions and 2 deletions
+666 -1
View File
@@ -18,6 +18,12 @@
let importAnimationDefinitionAction; let importAnimationDefinitionAction;
let automaticImportAction; let automaticImportAction;
let packFoldersAction; let packFoldersAction;
const PROP_PREVIEWS_PROPERTY = 'animationdirector_prop_previews';
const PROP_SLOTS = { left: 'propleft', right: 'propright', floor: 'propfloor' };
let propAction, propProjectProperty, propDialog, propListener;
const propMeshes = new Map();
const propSources = new Map();
let propPreviewSignature = null, propPreviewGeneration = 0;
const SYSTEM_TAG_OPTIONS = { const SYSTEM_TAG_OPTIONS = {
'': 'None (normal animation)', '': 'None (normal animation)',
manualpeak: 'Manual peak', manualpeak: 'Manual peak',
@@ -3817,7 +3823,647 @@
// Automatic import reads only explicitly registered, extracted packs. No network access. // Automatic import reads only explicitly registered, extracted packs. No network access.
const PACK_FOLDERS_KEY = 'animationdirector_pack_folders'; const PACK_FOLDERS_KEY = 'animationdirector_pack_folders';
const IMPORT_METADATA = [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY, const IMPORT_METADATA = [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY,
ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY]; ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY, PROP_PREVIEWS_PROPERTY];
// Item previews are private Three.js attachments, never actor geometry or animation tracks.
function propResourcePath(id, kind, extension) {
const value = String(id || '');
const resource = value.includes(':') ? value : `minecraft:${value}`;
if (!/^[a-z0-9_.-]+:[a-z0-9_./-]+$/.test(resource) || resource.split(':')[1].split('/').some((part) => !part || part === '.' || part === '..')) {
throw new Error(`Invalid item resource ID: ${value}`);
}
const [namespace, name] = resource.split(':');
return `assets/${namespace}/${kind}/${name}${extension}`;
}
function indexPropZip(bytes) {
const data = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes), files = new Map();
if (data.length > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.');
let end = -1;
for (let offset = data.length - 22; offset >= Math.max(0, data.length - 65557); offset--) {
if (data.readUInt32LE(offset) === 0x06054b50 && offset + 22 + data.readUInt16LE(offset + 20) === data.length) { end = offset; break; }
}
if (end < 0) throw new Error('Not a supported ZIP/JAR archive.');
if (data.readUInt16LE(end + 4) || data.readUInt16LE(end + 6)) throw new Error('Split archives are unsupported.');
const count = data.readUInt16LE(end + 10), start = data.readUInt32LE(end + 16);
const limit = start + data.readUInt32LE(end + 12);
if (count === 65535 || start === 0xffffffff || limit > end) throw new Error('ZIP64 or invalid archive directory.');
let offset = start;
for (let index = 0; index < count; index++) {
if (offset + 46 > limit || data.readUInt32LE(offset) !== 0x02014b50) throw new Error('Invalid archive directory.');
const nameLength = data.readUInt16LE(offset + 28), extra = data.readUInt16LE(offset + 30), comment = data.readUInt16LE(offset + 32);
if (offset + 46 + nameLength + extra + comment > limit) throw new Error('Truncated archive directory.');
const name = data.toString('utf8', offset + 46, offset + 46 + nameLength);
if (name.startsWith('assets/') && !name.endsWith('/') && !name.includes('\\') && !name.split('/').includes('..')) {
files.set(name, { flags: data.readUInt16LE(offset + 8), method: data.readUInt16LE(offset + 10),
size: data.readUInt32LE(offset + 24), compressed: data.readUInt32LE(offset + 20), local: data.readUInt32LE(offset + 42) });
}
offset += 46 + nameLength + extra + comment;
}
return { files, read(name) {
const file = files.get(name);
if (!file) return null;
if (file.flags & 1 || file.size > 16 * 1024 * 1024) throw new Error('Encrypted or oversized asset entry.');
const local = file.local;
if (local + 30 > data.length || data.readUInt32LE(local) !== 0x04034b50) throw new Error('Invalid asset entry.');
const start = local + 30 + data.readUInt16LE(local + 26) + data.readUInt16LE(local + 28);
if (start + file.compressed > data.length) throw new Error('Truncated asset entry.');
const content = data.subarray(start, start + file.compressed);
let decoded;
if (file.method === 0) decoded = content;
else if (file.method === 8) {
const load = typeof requireNativeModule === 'function' ? requireNativeModule : require;
const zlib = load('zlib');
if (!zlib?.inflateRawSync) throw new Error('ZIP decompression is unavailable. Allow zlib access or use extracted assets.');
decoded = zlib.inflateRawSync(content, { maxOutputLength: 16 * 1024 * 1024 });
} else throw new Error('Unsupported asset compression method.');
if (decoded.length !== file.size) throw new Error('Asset entry size mismatch.');
return decoded;
} };
}
function openPropSource(file, io = desktopFiles()) {
const resolved = importFilePath(file, io), stats = io.fs.statSync(resolved);
const signature = `${resolved}:${stats.mtimeMs}:${stats.size}`;
if (propSources.has(signature)) return propSources.get(signature);
let source;
if (stats.isDirectory()) {
const root = io.path.basename(resolved) === 'assets' ? io.path.dirname(resolved) : resolved;
source = { read(name) {
const target = io.path.join(root, ...name.split('/'));
if (!io.fs.existsSync(target)) return null;
if (!insidePack(root, importFilePath(target, io), io)) throw new Error('Asset path escapes the selected folder.');
if (io.fs.statSync(target).size > 16 * 1024 * 1024) throw new Error('Asset exceeds the 16 MiB limit.');
return Buffer.from(io.fs.readFileSync(target));
}, itemIds() {
const ids = [], assets = io.path.join(root,'assets');
if (!io.fs.existsSync(assets)) return ids;
for (const namespace of io.fs.readdirSync(assets).slice(0,256)) {
const folder = io.path.join(assets,namespace,'items');
if (!io.fs.existsSync(folder)) continue;
const scan = (folder,prefix = '',depth = 0) => {
if (depth > 8 || ids.length >= 10000) return;
insidePack(root,folder,io);
for (const entry of io.fs.readdirSync(folder,{withFileTypes:true})) {
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) scan(io.path.join(folder,entry.name),`${prefix}${entry.name}/`,depth+1);
else if (entry.name.endsWith('.json')) ids.push(`${namespace}:${prefix}${entry.name.slice(0,-5)}`);
if (ids.length >= 10000) break;
}
};
scan(folder);
}
return ids.sort();
} };
} else {
if (stats.size > 512 * 1024 * 1024) throw new Error('Asset archive exceeds the 512 MiB limit.');
source = indexPropZip(io.fs.readFileSync(resolved));
}
source.path = resolved;
// Keep only the most recently used archive rather than retaining several entire client JARs.
propSources.clear(); propSources.set(signature, source);
return source;
}
function discoverPropSources(io = desktopFiles()) {
const results = [];
const add = (file, label) => { if (io.fs.existsSync(file) && !results.some((entry) => entry.file === file)) results.push({ file, label }); };
if (typeof SystemInfo !== 'undefined' && SystemInfo.user_data_directory) {
const cache = io.path.join(SystemInfo.user_data_directory, 'minecraft_assets_cache');
if (io.fs.existsSync(cache)) for (const name of io.fs.readdirSync(cache).slice(0, 500)) {
if (/\.jar$/i.test(name)) add(io.path.join(cache, name), `Asset Browser: ${name.replace(/\.jar$/i, '')}`);
}
}
let minecraft = typeof settings !== 'undefined' ? settings.ewan_minecraft_directory?.value : null;
if (!minecraft && typeof SystemInfo !== 'undefined') {
if (SystemInfo.platform === 'win32' && SystemInfo.appdata_directory) minecraft = io.path.join(SystemInfo.appdata_directory, '.minecraft');
else if (SystemInfo.home_directory) minecraft = io.path.join(SystemInfo.home_directory,
...(SystemInfo.platform === 'darwin' ? ['Library', 'Application Support', 'minecraft'] : ['.minecraft']));
}
if (minecraft) {
const versions = io.path.join(minecraft, 'versions');
if (io.fs.existsSync(versions)) for (const name of io.fs.readdirSync(versions).slice(0, 500)) {
add(io.path.join(versions, name, `${name}.jar`), `Minecraft: ${name}`);
}
}
const last = typeof localStorage !== 'undefined' ? localStorage.getItem('animationdirector_prop_source') : null;
if (last) add(last, `Selected: ${io.path.basename(last)}`);
return results;
}
function propJson(source, path) {
const content = source.read(path);
return content ? JSON.parse(content.toString('utf8').replace(/^\uFEFF/, '')) : null;
}
function resolvePropModel(source, id, ancestors = new Set()) {
const path = propResourcePath(id, 'models', '.json');
if (ancestors.has(path) || ancestors.size >= 32) throw new Error(`Cyclic or excessive item model inheritance: ${id}`);
const json = propJson(source, path);
if (!json) throw new Error(`Missing item model: ${id}`);
ancestors = new Set(ancestors); ancestors.add(path);
let parent = {};
if (json.parent && !/^(minecraft:)?builtin\//.test(json.parent)) parent = resolvePropModel(source, json.parent, ancestors);
const result = { ...parent, ...json, textures: { ...parent.textures, ...json.textures }, display: { ...parent.display, ...json.display } };
result.generated = json.parent && /^(minecraft:)?builtin\/generated$/.test(json.parent) || parent.generated || false;
return result;
}
function propItemModels(node, warnings, depth = 0) {
if (!node || depth > 20) throw new Error('Missing or excessive item definition tree.');
const type = String(node.type || '').replace(/^minecraft:/, '');
if (type === 'model' && typeof node.model === 'string') {
if (node.tints?.length) warnings.push('This item uses component-dependent tinting; the preview uses its default tint.');
return [{ id: node.model, tints: node.tints || [] }];
}
if (type === 'composite') return (node.models || []).flatMap((child) => propItemModels(child, warnings, depth + 1));
if (['condition', 'select', 'range_dispatch'].includes(type)) {
warnings.push('Component-dependent item appearance: previewing its fallback/default branch.');
const child = type === 'condition' ? node.on_false : node.fallback;
if (!child) throw new Error('This conditional item has no default appearance. Use a manual texture preview.');
return propItemModels(child, warnings, depth + 1);
}
throw new Error(`Item renderer '${type || 'unknown'}' is not supported. Use a manual texture preview.`);
}
function propTextureId(value, textures) {
const seen = new Set();
while (typeof value === 'string' && value.startsWith('#')) {
if (seen.has(value)) throw new Error(`Cyclic item texture reference: ${value}`);
seen.add(value); value = textures[value.slice(1)];
}
if (typeof value !== 'string') throw new Error('Missing item texture reference.');
return value;
}
function propDisplayTransform(model, slot) {
const context = slot === 'floor' ? 'ground' : `thirdperson_${slot}hand`;
const explicit = model.display?.[context];
const data = explicit || (slot === 'left' ? model.display?.thirdperson_righthand : null) || {};
const rotation = v3(data.rotation), translation = v3(data.translation), scale = v3(data.scale, 1);
if (slot === 'left' && !explicit) { translation[0] *= -1; rotation[1] *= -1; rotation[2] *= -1; }
return { rotation, translation, scale };
}
function manualPropModel(preset) {
const generated = { generated: true, display: {
ground: { translation: [0, 2, 0], scale: [0.5, 0.5, 0.5] },
thirdperson_righthand: { translation: [0, 3, 1], scale: [0.55, 0.55, 0.55] },
} };
if (preset === 'handheld') generated.display = { ...generated.display,
thirdperson_righthand: { rotation: [0, -90, 55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] },
thirdperson_lefthand: { rotation: [0, 90, -55], translation: [0, 4, 0.5], scale: [0.85, 0.85, 0.85] } };
return generated;
}
function loadPropAsset(source, item, slot) {
propResourcePath(item, 'items', '.json');
const warnings = [], definition = propJson(source, propResourcePath(item, 'items', '.json'));
const refs = definition ? propItemModels(definition.model, warnings) : [{ id: `${item.includes(':') ? item.split(':')[0] : 'minecraft'}:item/${item.split(':').pop()}`, tints: [] }];
const parts = [];
for (const ref of refs) {
const model = resolvePropModel(source, ref.id), transform = propDisplayTransform(model, slot);
const textures = {}, frames = {};
const addTexture = (value) => {
const id = propTextureId(value, model.textures);
const png = source.read(propResourcePath(id, 'textures', '.png'));
if (!png || png.length > 4 * 1024 * 1024) throw new Error(`Missing or oversized item texture: ${id}`);
textures[id] ||= `data:image/png;base64,${png.toString('base64')}`;
const metadata = propJson(source, propResourcePath(id, 'textures', '.png.mcmeta'));
if (metadata?.animation) {
warnings.push('Animated texture: the preview uses its first frame.');
frames[id] = metadata.animation;
}
return id;
};
if (model.generated) {
for (let layer = 0; layer < 5; layer++) {
if (!model.textures[`layer${layer}`]) continue;
const id = addTexture(model.textures[`layer${layer}`]);
const tint = ref.tints[layer];
const color = tint?.value ?? tint?.default;
parts.push({ generated: true, texture: id, png: textures[id], frame: frames[id], transform,
tint: typeof color === 'number' ? color & 0xffffff : 0xffffff });
}
} else if (Array.isArray(model.elements)) {
if (model.elements.length > 2048) throw new Error('Item model exceeds the preview element limit.');
const elements = clone(model.elements);
for (const element of elements) for (const face of Object.values(element.faces || {})) face.texture = addTexture(face.texture);
parts.push({ elements, textures, frames, transform,
tints: ref.tints.map((tint) => typeof (tint.value ?? tint.default) === 'number' ? (tint.value ?? tint.default) & 0xffffff : 0xffffff) });
} else throw new Error(`Model '${ref.id}' requires a special renderer. Use a manual texture preview.`);
}
if (!parts.length) throw new Error('The item model has no previewable geometry.');
return { parts, warnings: [...new Set(warnings)] };
}
function readPropPreviews() {
try { return JSON.parse(Project?.[PROP_PREVIEWS_PROPERTY] || '{}'); } catch (_) { return {}; }
}
function propEntryKey(animation, actor, slot, stage) { return JSON.stringify([animation, actor, slot, Number(stage)]); }
function effectivePropEntry(entries, animation, actor, slot, stage) {
return entries[propEntryKey(animation, actor, slot, stage)] ?? entries[propEntryKey(animation, actor, slot, 0)];
}
function effectivePropItem(definition, actor, slot, stageNumber) {
const field = `prop_${slot}`, stage = definition.stages?.find((stage) => stage.stage === stageNumber);
if (stage?.props === null || stage?.props?.[actor] === null) return null;
const props = stage?.props?.[actor];
if (props && Object.hasOwn(props, field)) return props[field];
return definition.actors?.find((entry) => entry.label === actor)?.[field];
}
function writePropDefinition(definition, actor, slot, stage, mode, item) {
const result = clone(definition), field = `prop_${slot}`;
if (!['item','hidden','inherit'].includes(mode) || !Number.isInteger(Number(stage)) || Number(stage) < 0) throw new Error('Choose a valid prop behavior and stage.');
if (!PROP_SLOTS[slot] || !result.actors?.some((entry) => entry.label === actor)) throw new Error('Choose a valid actor and prop slot.');
if (mode === 'item') propResourcePath(item, 'items', '.json');
if (!stage) {
const entry = result.actors.find((entry) => entry.label === actor);
if (mode === 'item') entry[field] = item; else delete entry[field];
return result;
}
const entry = result.stages.find((entry) => entry.stage === Number(stage));
if (!entry) throw new Error(`Missing definition stage p${stage}.`);
// Expanding an explicit all-hidden override must not accidentally unhide other slots/actors.
if (entry.props === null) entry.props = Object.fromEntries(result.actors.map((entry) => [entry.label, null]));
entry.props ||= {};
if (entry.props[actor] === null) entry.props[actor] = { prop_left: null, prop_right: null, prop_floor: null };
entry.props[actor] ||= {};
if (mode === 'inherit') delete entry.props[actor][field];
else entry.props[actor][field] = mode === 'hidden' ? null : item;
if (!Object.keys(entry.props[actor]).length) delete entry.props[actor];
if (!Object.keys(entry.props).length) delete entry.props;
return result;
}
function propAuthoringDefinition(id) {
const group = collectConjoinedAnimationGroups().groups.find((group) => group.id === id);
if (!group) throw new Error('Select an animation with actor tracks first.');
const analysis = analyzeAnimationDefinitionGroup(group), saved = getSavedAnimdef(id);
const actors = analysis.actorIndices.map((index) => {
const label = analysis.actorLabels.get(index), old = saved.actors?.find((actor) => actor.label === label);
return { ...buildActorConstraint(label, getProjectActorAnimdefMetadata()[index] || {}), ...old, label };
});
const stages = saved.stages?.length ? saved.stages : analysis.stages.map((stage) => ({ stage: stage.stageNumber, loop: stage.loop, cycle_seconds: stage.cycleSeconds }));
return { ...saved, actors, stages };
}
async function decodePropPng(url, frame) {
if (typeof Image === 'undefined' || typeof document === 'undefined') throw new Error('Image previews require Blockbench.');
const image = await new Promise((resolve, reject) => {
const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('Could not decode the preview PNG.')); image.src = url;
});
if (image.width > 1024 || image.height > 8192 || !image.width || !image.height) throw new Error('Preview PNG dimensions are unsupported.');
const width = frame?.width || image.width, height = frame ? frame.height || width : image.height;
const first = frame?.frames?.[0], index = typeof first === 'number' ? first : first?.index || 0;
const columns = Math.floor(image.width / width), x = (index % columns) * width, y = Math.floor(index / columns) * height;
if (!Number.isInteger(width) || !Number.isInteger(height) || width * height > 65536 || !Number.isInteger(index)
|| index < 0 || width <= 0 || height <= 0 || x + width > image.width || y + height > image.height) {
throw new Error('Invalid or oversized animated texture frame.');
}
const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
const context = canvas.getContext('2d'); context.drawImage(image,x,y,width,height,0,0,width,height);
return { width, height, pixels: context.getImageData(0,0,width,height).data, canvas };
}
function extrudePropPixels(width, height, pixels) {
if (width * height > 65536) throw new Error('Extruded previews are limited to 65,536 pixels.');
const elements = [], solid = (x, y) => x >= 0 && y >= 0 && x < width && y < height && pixels[(y * width + x) * 4 + 3] > 0;
// Front/back use one alpha-cutout plane. Only silhouette edges need thickness geometry.
const faces = { north: { texture: 'sprite', uv: [16, 0, 0, 16] }, south: { texture: 'sprite', uv: [0, 0, 16, 16] } };
elements.push({ from: [0, 0, 7.5], to: [16, 16, 8.5], faces });
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) if (solid(x, y)) {
const edges = {};
const uv = [(x + 0.5) * 16 / width, (y + 0.5) * 16 / height];
const face = () => ({ texture: 'sprite', uv: [...uv, ...uv] });
if (!solid(x - 1, y)) edges.west = face();
if (!solid(x + 1, y)) edges.east = face();
if (!solid(x, y - 1)) edges.up = face();
if (!solid(x, y + 1)) edges.down = face();
if (Object.keys(edges).length) elements.push({ from: [x * 16 / width, 16 - (y + 1) * 16 / height, 7.5],
to: [(x + 1) * 16 / width, 16 - y * 16 / height, 8.5], faces: edges });
}
return elements;
}
function propFaceVertices(from, to, face) {
const [x, y, z] = from, [X, Y, Z] = to;
return { north: [[X,y,z],[x,y,z],[x,Y,z],[X,Y,z]], south: [[x,y,Z],[X,y,Z],[X,Y,Z],[x,Y,Z]],
west: [[x,y,z],[x,y,Z],[x,Y,Z],[x,Y,z]], east: [[X,y,Z],[X,y,z],[X,Y,z],[X,Y,Z]],
up: [[x,Y,Z],[X,Y,Z],[X,Y,z],[x,Y,z]], down: [[x,y,z],[X,y,z],[X,y,Z],[x,y,Z]] }[face];
}
function propDefaultUv(from, to, face) {
const [x,y,z] = from, [X,Y,Z] = to;
return { north: [16-X,16-Y,16-x,16-y], south: [x,16-Y,X,16-y], west: [z,16-Y,Z,16-y],
east: [16-Z,16-Y,16-z,16-y], up: [x,z,X,Z], down: [x,16-Z,X,16-z] }[face];
}
async function buildPropMesh(asset) {
const root = new THREE.Group(), images = new Map();
try {
for (const [partIndex, part] of asset.parts.entries()) {
const pngs = part.generated ? { sprite: part.png } : part.textures;
const partImages = new Map();
for (const [id, url] of Object.entries(pngs)) {
const frame = part.generated ? part.frame : part.frames?.[id], key = url + JSON.stringify(frame || null);
if (!images.has(key)) images.set(key, await decodePropPng(url, frame));
partImages.set(id, images.get(key));
}
const sprite = partImages.get('sprite');
const elements = part.generated ? extrudePropPixels(sprite.width, sprite.height, sprite.pixels) : part.elements;
const transform = new THREE.Group(); root.add(transform);
transform.position.fromArray(part.transform.translation);
transform.rotation.set(...part.transform.rotation.map((value) => value * Math.PI / 180), 'XYZ');
transform.scale.fromArray(part.transform.scale);
const centered = new THREE.Group(); centered.position.set(-8, -8, -8); transform.add(centered);
const buffers = new Map();
for (const element of elements) {
const from = v3(element.from), to = v3(element.to);
const rotation = element.rotation;
const matrix = new THREE.Matrix4();
if (rotation) {
if (!['x', 'y', 'z'].includes(rotation.axis)) throw new Error('Unsupported item element rotation axis.');
const pivot = v3(rotation.origin, 8), angle = num(rotation.angle) * Math.PI / 180;
const quaternion = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(...['x','y','z'].map((axis) => axis === rotation.axis ? 1 : 0)), angle);
const scale = new THREE.Vector3(1,1,1);
if (rotation.rescale) for (const axis of ['x','y','z']) if (axis !== rotation.axis) scale[axis] = 1 / Math.cos(angle);
matrix.compose(new THREE.Vector3(...pivot), quaternion, scale).multiply(new THREE.Matrix4().makeTranslation(...pivot.map((value) => -value)));
}
for (const [side, face] of Object.entries(element.faces || {})) {
const vertices = propFaceVertices(from, to, side);
if (!vertices || !pngs[face.texture]) continue;
const tint = part.tint ?? part.tints?.[face.tintindex] ?? 0xffffff, key = `${face.texture}:${tint}`;
if (!buffers.has(key)) buffers.set(key, { texture: face.texture, tint, positions: [], uvs: [] });
const buffer = buffers.get(key), uv = face.uv || propDefaultUv(from,to,side);
const coords = [[uv[0],uv[3]],[uv[2],uv[3]],[uv[2],uv[1]],[uv[0],uv[1]]];
const turns = ((face.rotation || 0) / 90) % 4;
for (let count = 0; count < turns; count++) coords.unshift(coords.pop());
for (const index of [0,1,2,0,2,3]) {
const vector = new THREE.Vector3(...vertices[index]).applyMatrix4(matrix);
buffer.positions.push(vector.x,vector.y,vector.z);
buffer.uvs.push(coords[index][0]/16, 1-coords[index][1]/16);
}
}
}
for (const buffer of buffers.values()) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(buffer.positions,3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(buffer.uvs,2)); geometry.computeVertexNormals();
const texture = new THREE.CanvasTexture(partImages.get(buffer.texture).canvas);
texture.magFilter = THREE.NearestFilter; texture.minFilter = THREE.NearestFilter;
if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
else if (THREE.sRGBEncoding) texture.encoding = THREE.sRGBEncoding;
const material = new THREE.MeshLambertMaterial({ map: texture, side: THREE.DoubleSide, alphaTest: 0.01,
transparent: false, color: buffer.tint, polygonOffset: !!part.generated,
polygonOffsetFactor: -partIndex, polygonOffsetUnits: -partIndex });
centered.add(new THREE.Mesh(geometry, material));
}
}
// GeckoLib -> Blockbench coordinate conversion mirrors model X, including attached items.
root.scale.x = -1;
return root;
} catch (error) { disposePropMesh(root); throw error; }
}
function disposePropMesh(root) {
root.parent?.remove(root);
root.traverse((node) => { node.geometry?.dispose(); if (node.material) { node.material.map?.dispose(); node.material.dispose(); } });
}
function clearPropMeshes() {
propPreviewGeneration++;
for (const mesh of propMeshes.values()) disposePropMesh(mesh);
propMeshes.clear(); propPreviewSignature = null;
}
async function refreshPropPreviews(force = false) {
const selected = typeof Animation !== 'undefined' ? parseAfwStageAnimationName(getSelectedAnimation()) : null;
const raw = Project?.[PROP_PREVIEWS_PROPERTY] || '{}';
if (raw === '{}' && !propMeshes.size) return;
const bones = typeof Group !== 'undefined' ? Group.all.filter((group) => /_prop(left|right|floor)$/.test(group.name)).map((group) => `${group.uuid}:${group.name}`).join(',') : '';
const definitions = Project?.[ANIMDEF_DEFINITIONS_PROPERTY];
const signature = { project: Project?.uuid, animation: selected?.name, raw, bones, definitions };
if (!force && signature.project === propPreviewSignature?.project && signature.animation === propPreviewSignature?.animation
&& raw === propPreviewSignature?.raw && bones === propPreviewSignature?.bones && definitions === propPreviewSignature?.definitions) {
for (const [uuid, mesh] of propMeshes) {
const bone = Group.all.find((group) => group.uuid === uuid);
if (bone?.mesh && mesh.parent !== bone.mesh) bone.mesh.add(mesh);
}
return;
}
clearPropMeshes(); propPreviewSignature = signature;
if (!Project || !selected || typeof THREE === 'undefined') return;
const generation = propPreviewGeneration, entries = readPropPreviews(), definition = getSavedAnimdef(selected.id);
for (const index of getSortedUsedActorIndicesFromProject()) for (const slot of Object.keys(PROP_SLOTS)) {
const actor = getActorExportLabel(index), entry = effectivePropEntry(entries, selected.id, actor, slot, selected.stageNumber);
if (!entry?.asset || entry.mode !== 'item') continue;
const item = effectivePropItem(definition, actor, slot, selected.stageNumber);
if (item === null) continue;
const bone = Group.all.find((group) => group.name === `actor${index}_${PROP_SLOTS[slot]}`);
if (!bone?.mesh) continue;
try {
const asset = item && item !== entry.item ? (!entry.texture && entry.source ? loadPropAsset(openPropSource(entry.source),item,slot) : null) : entry.asset;
if (!asset) continue;
const mesh = await buildPropMesh(asset);
if (generation !== propPreviewGeneration) { disposePropMesh(mesh); return; }
bone.mesh.add(mesh); propMeshes.set(bone.uuid, mesh);
} catch (error) { console.warn('Could not build item prop preview:', error); }
}
}
async function applyPropPreview(values) {
const projectId = Project?.uuid;
if (!projectId) throw new Error('Open an animation project first.');
const definition = propAuthoringDefinition(values.animation), stage = Number(values.stage), slot = values.slot, actor = values.actor;
const item = String(values.item || '').trim();
const updated = writePropDefinition(definition, actor, slot, stage, values.mode, item);
let asset = null;
if (values.mode === 'item') {
if (values.texture) {
const io = desktopFiles(), png = io.fs.readFileSync(values.texture);
if (png.length > 4 * 1024 * 1024) throw new Error('Manual texture exceeds the 4 MiB limit.');
asset = { parts: [{ generated: true, png: `data:image/png;base64,${Buffer.from(png).toString('base64')}`,
transform: propDisplayTransform(manualPropModel(values.preset),slot) }], warnings: [] };
} else {
if (!values.source) throw new Error('Select a Minecraft JAR/assets folder or a manual PNG texture.');
asset = loadPropAsset(openPropSource(values.source), item, slot);
}
// Validate image/geometry before changing project metadata or opening an undo edit.
const validation = await buildPropMesh(asset); disposePropMesh(validation);
}
if (Project?.uuid !== projectId) throw new Error('The active project changed. Try again.');
const key = propEntryKey(values.animation,actor,slot,stage), entries = readPropPreviews();
if (values.mode === 'inherit') delete entries[key];
else entries[key] = { animation: values.animation, actor, slot, stage, mode: values.mode, item,
source: values.source, texture: values.texture, preset: values.preset, asset };
const raw = JSON.stringify(entries);
if (raw.length > 24 * 1024 * 1024) throw new Error('Project prop previews exceed the 24 MiB embedded asset limit.');
Undo.initEdit({ prop_preview_edit: true, animdef_edit: true });
Project[PROP_PREVIEWS_PROPERTY] = raw; saveAnimdef(values.animation, updated);
Undo.finishEdit('Edit item prop', { prop_preview_edit: true, animdef_edit: true });
Project.saved = false;
await refreshPropPreviews(true);
return asset?.warnings || [];
}
function createPropDialog() {
const animationIds = collectConjoinedAnimationGroups().groups.map((group) => group.id);
const selected = parseAfwStageAnimationName(getSelectedAnimation());
const state = {
projectId: Project.uuid, animation: selected && animationIds.includes(selected.id) ? selected.id : animationIds[0] || '',
actor: '', slot: 'right', stage: '0', source: '', jar: '', folder: '', search: '', choice: '',
actors: [], stages: [], sources: discoverPropSources(), items: [], busy: false,
};
const addSource = (file) => {
if (file && !state.sources.some((entry) => entry.file === file)) state.sources.push({ file, label: `Selected: ${file}` });
};
const indexItems = () => {
state.items = [];
if (!state.source) return;
const source = openPropSource(state.source);
if (source.files) {
const modern = [...source.files.keys()].filter((name) => /^assets\/[^/]+\/items\/.+\.json$/.test(name));
state.items = modern.length ? modern.map((name) => name.replace(/^assets\/([^/]+)\/items\/(.+)\.json$/, '$1:$2')).sort()
: [...source.files.keys()].filter((name) => /^assets\/[^/]+\/models\/item\/.+\.json$/.test(name))
.map((name) => name.replace(/^assets\/([^/]+)\/models\/item\/(.+)\.json$/, '$1:$2')).sort();
} else state.items = source.itemIds();
};
const selectAnimation = () => {
const definition = state.animation ? propAuthoringDefinition(state.animation) : { actors: [], stages: [] };
state.actors = definition.actors.map((actor) => actor.label);
state.stages = definition.stages.map((stage) => stage.stage);
if (!state.actors.includes(state.actor)) state.actor = state.actors[0] || '';
if (!state.stages.includes(Number(state.stage))) state.stage = '0';
};
const savedEntry = () => {
const entry = readPropPreviews()[propEntryKey(state.animation, state.actor, state.slot, state.stage)];
const definition = getSavedAnimdef(state.animation), field = `prop_${state.slot}`;
const fallback = definition.actors?.find((actor) => actor.label === state.actor)?.[field];
const stage = definition.stages?.find((stage) => stage.stage === Number(state.stage));
const override = stage?.props?.[state.actor];
const value = Number(state.stage) ? (stage?.props === null || override === null ? null : override?.[field]) : fallback;
if (entry?.source) { addSource(entry.source); state.source = entry.source; }
return {
mode: entry?.mode || (value === null ? 'hidden' : value !== undefined ? 'item' : Number(state.stage) ? 'inherit' : 'item'),
item: entry?.item || value || fallback || 'minecraft:carrot',
texture: entry?.texture || '', preset: entry?.preset || 'generated',
};
};
selectAnimation();
state.source = localStorage.getItem('animationdirector_prop_source') || state.sources[0]?.file || '';
const initial = savedEntry();
addSource(state.source);
try { indexItems(); } catch (error) { console.warn('Could not index item assets:', error); state.items = []; }
const form = {
_target: { type: 'info', text: '### Prop target\nChoose the actor bone and the stages that should use this item.' },
animation: { label: 'Animation', type: 'select', value: state.animation,
options: Object.fromEntries(animationIds.map((id) => [id, id])) },
actor: { label: 'Actor', type: 'select', value: state.actor,
options: () => Object.fromEntries(state.actors.map((id) => [id, id])) },
slot: { label: 'Prop slot', type: 'select', value: state.slot,
options: { right: 'Right hand', left: 'Left hand', floor: 'Floor' } },
stage: { label: 'Scope', type: 'select', value: state.stage,
options: () => ({ 0: 'Default (all stages)', ...Object.fromEntries(state.stages.map((id) => [String(id), `Stage p${id}`])) }) },
mode: { label: 'Behavior', type: 'select', value: initial.mode,
options: () => ({ item: 'Use item', ...state.stage !== '0' ? { hidden: 'Hide prop' } : {},
inherit: state.stage === '0' ? 'Remove default prop' : 'Inherit default prop' }) },
_assets: { type: 'info', text: '### Minecraft assets\nUse local Minecraft assets or a manual PNG for the editor preview. The preview is not exported.' ,
condition: (result) => result.mode === 'item' },
source: { label: 'Version / asset source', type: 'select', value: state.source,
options: () => ({ '': 'Select assets...', ...Object.fromEntries(state.sources.map((entry) => [entry.file, entry.label])) }),
condition: (result) => result.mode === 'item' },
jar: { label: 'Add client JAR', type: 'file', extensions: ['jar', 'zip'], readtype: 'none', value: '',
condition: (result) => result.mode === 'item' },
folder: { label: 'Add extracted assets folder', type: 'folder', value: '',
condition: (result) => result.mode === 'item' },
item: { label: 'Item ID', type: 'text', value: initial.item, placeholder: 'minecraft:carrot',
description: 'The in-game item saved to the animdef.', condition: (result) => result.mode === 'item' },
item_search: { label: 'Search available items', type: 'text', value: '', placeholder: 'Search by item ID',
condition: (result) => result.mode === 'item' && !!state.items.length },
item_choice: { label: 'Choose item', type: 'select', value: '',
options: () => ({ '': 'Select a matching item...', ...Object.fromEntries(state.items
.filter((id) => id.includes(state.search.toLowerCase())).slice(0, 100).map((id) => [id, id])) }),
condition: (result) => result.mode === 'item' && !!state.items.length },
_manual: { type: 'info', text: '### Manual texture fallback\nFor items with a special renderer, choose a PNG to preview an extruded sprite. This does not change the in-game model.',
condition: (result) => result.mode === 'item' },
texture: { label: 'Preview texture (optional)', type: 'file', extensions: ['png'], readtype: 'none', value: initial.texture,
condition: (result) => result.mode === 'item' },
preset: { label: 'Display preset', type: 'select', value: initial.preset,
options: { generated: 'Generated item (food, materials)', handheld: 'Handheld item (tools, swords)' },
condition: (result) => result.mode === 'item' && !!result.texture },
};
const dialog = new Dialog({ id: 'animationdirector_props', title: 'Item Props', width: 760, form,
buttons: ['Cancel', 'Apply prop'], cancelIndex: 0, confirmIndex: 1,
onFormChange(result) {
const nextSource = result.jar && result.jar !== state.jar ? result.jar
: result.folder && result.folder !== state.folder ? result.folder : result.source;
state.jar = result.jar; state.folder = result.folder;
if (nextSource !== state.source) {
state.source = nextSource; addSource(nextSource);
this.form.setValues({ source: nextSource, item_choice: '' }, false);
state.choice = '';
try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
}
if (result.animation !== state.animation) {
state.animation = result.animation; state.actor = ''; state.stage = '0'; selectAnimation();
const previousSource = state.source, entry = savedEntry();
this.form.setValues({ actor: state.actor, stage: state.stage, source: state.source, ...entry }, false);
if (state.source !== previousSource) try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
} else if (result.actor !== state.actor || result.slot !== state.slot || result.stage !== state.stage) {
state.actor = result.actor; state.slot = result.slot; state.stage = result.stage;
const previousSource = state.source, entry = savedEntry();
this.form.setValues({ source: state.source, ...entry }, false);
if (state.source !== previousSource) try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); }
}
if (result.item_search !== state.search) {
state.search = result.item_search || '';
state.choice = '';
this.form.setValues({ item_choice: '' }, false);
} else if (result.item_choice && result.item_choice !== state.choice) {
state.choice = result.item_choice;
this.form.setValues({ item: result.item_choice }, false);
}
this.form.update(this.form.getResult());
},
onConfirm(result) {
if (state.busy) return false;
state.busy = true;
const values = { animation: result.animation, actor: result.actor, slot: result.slot, stage: result.stage,
mode: result.mode, item: result.item, source: state.source, texture: result.texture, preset: result.preset };
void applyPropPreview(values).then((warnings) => {
localStorage.setItem('animationdirector_prop_source', state.source);
const index = getSortedUsedActorIndicesFromProject().find((index) => getActorExportLabel(index) === values.actor);
const missingBone = !Group.all.some((group) => group.name === `actor${index}_${PROP_SLOTS[values.slot]}`);
this.hide();
Blockbench.showQuickMessage([`Item prop saved.`, ...warnings,
...(missingBone ? [`Add the ${PROP_SLOTS[values.slot]} bone to see its preview.`] : [])].join(' '), 4500);
}).catch((error) => {
Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] });
}).finally(() => { state.busy = false; });
return false;
},
});
propDialog = dialog;
}
function showPropDialog() {
if (!Project) return Blockbench.showQuickMessage('Open an animation project first.', 2500);
try {
if (!collectConjoinedAnimationGroups().groups.length) {
Blockbench.showQuickMessage('Create or import an animation before editing item props.', 3000);
return;
}
propDialog?.delete();
createPropDialog();
propDialog.show();
} catch (error) {
Blockbench.showMessageBox({ title: 'Item Props', message: error.message, buttons: ['OK'] });
}
}
function desktopFiles() { function desktopFiles() {
const load = typeof requireNativeModule === 'function' ? requireNativeModule const load = typeof requireNativeModule === 'function' ? requireNativeModule
@@ -4387,6 +5033,9 @@ You can import or export complete AFW animation IDs as one file containing all s
default: '{}', default: '{}',
}); });
} }
if (!ModelProject.properties[PROP_PREVIEWS_PROPERTY]) {
propProjectProperty = new Property(ModelProject, 'string', PROP_PREVIEWS_PROPERTY, { default: '{}' });
}
} }
blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element); blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element);
@@ -4398,6 +5047,7 @@ You can import or export complete AFW animation IDs as one file containing all s
blockRequirementUndoSaveListener = ({ save, aspects }) => { blockRequirementUndoSaveListener = ({ save, aspects }) => {
if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}'; if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}';
if (aspects.animdef_edit) save[ANIMDEF_DEFINITIONS_PROPERTY] = Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}'; if (aspects.animdef_edit) save[ANIMDEF_DEFINITIONS_PROPERTY] = Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}';
if (aspects.prop_preview_edit) save[PROP_PREVIEWS_PROPERTY] = Project[PROP_PREVIEWS_PROPERTY] || '{}';
if (aspects.automatic_animation_import) { if (aspects.automatic_animation_import) {
for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}'); for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}');
} }
@@ -4409,9 +5059,19 @@ You can import or export complete AFW animation IDs as one file containing all s
if (save[property] !== undefined) Project[property] = save[property]; if (save[property] !== undefined) Project[property] = save[property];
} }
scheduleActorTexturePreviewApply(); scheduleActorTexturePreviewApply();
void refreshPropPreviews(true);
}; };
Blockbench.on('create_undo_save', blockRequirementUndoSaveListener); Blockbench.on('create_undo_save', blockRequirementUndoSaveListener);
Blockbench.on('load_undo_save', blockRequirementUndoLoadListener); Blockbench.on('load_undo_save', blockRequirementUndoLoadListener);
propListener = () => { void refreshPropPreviews().catch((error) => {
console.warn('Could not refresh item prop preview:', error);
}); };
Blockbench.on('select_animation select_project load_project unselect_project undo redo render_frame', propListener);
propAction = new Action('animationdirector_item_props', {
name: 'Item Props', icon: 'interests', description: 'Preview item props on actor bones and configure default/per-stage animdef props.',
click: showPropDialog,
});
blockRequirementAction = new Action('animationdirector_add_block_requirement', { blockRequirementAction = new Action('animationdirector_add_block_requirement', {
name: 'Block Requirements', name: 'Block Requirements',
@@ -4516,6 +5176,7 @@ You can import or export complete AFW animation IDs as one file containing all s
replaceActorAction, replaceActorAction,
exportConjoinedAction, exportConjoinedAction,
blockRequirementAction, blockRequirementAction,
propAction,
createAnimationDefinitionAction, createAnimationDefinitionAction,
], ],
}); });
@@ -4529,6 +5190,10 @@ You can import or export complete AFW animation IDs as one file containing all s
MenuBar.update(); MenuBar.update();
}, },
onunload() { onunload() {
clearPropMeshes(); propSources.clear();
propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete();
if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener);
propDialog = undefined; propListener = undefined;
clearScheduledActorTexturePreviewApplies(); clearScheduledActorTexturePreviewApplies();
if (actorTexturePreviewProjectListener) { if (actorTexturePreviewProjectListener) {
for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) { for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) {
+18
View File
@@ -69,6 +69,24 @@ test('metadata-only playback stages remain editable and survive definition expor
assert.equal(h.analysis.stages.length, 1); assert.equal(h.analysis.stages.length, 1);
}); });
test('prop tool metadata survives generator export including reused-stage overrides', () => {
const h = createHarness();
h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: {
actors: [{label:'player',entity_types:['minecraft:player'],prop_right:'minecraft:carrot'},
{label:'wolf',entity_types:['minecraft:wolf'],prop_floor:'minecraft:stone'}],
stages: [{stage:1,props:{player:{prop_left:'minecraft:stick'}}},
{stage:2,use_stage:1,props:{player:{prop_right:null},wolf:null}}],
} });
const dialog=h.open(); dialog.onConfirm(defaults(dialog.form));
assert.deepEqual(h.errors,[]);
const def=h.exports[0];
assert.equal(def.actors[0].prop_right,'minecraft:carrot');
assert.equal(def.actors[1].prop_floor,'minecraft:stone');
assert.equal(def.stages[0].props.player.prop_left,'minecraft:stick');
assert.equal(def.stages[1].props.player.prop_right,null);
assert.equal(def.stages[1].props.wolf,null);
});
test('generates extended animdef fields and restores them on reopen', () => { test('generates extended animdef fields and restores them on reopen', () => {
const harness = createHarness(); const harness = createHarness();
const dialog = harness.open(); const dialog = harness.open();
+1 -1
View File
@@ -106,7 +106,7 @@ test('animation menu orders import and creation actions and has no deprecated sp
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims'); const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
assert.deepEqual(Array.from(menu.children, (action) => action.name), [ assert.deepEqual(Array.from(menu.children, (action) => action.name), [
'Automatically Import Animation', 'Manual Import', 'Actor Preferences', 'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
'Replace Actor Model', 'Export Animations', 'Block Requirements', 'Create Animation Definition', 'Replace Actor Model', 'Export Animations', 'Block Requirements', 'Item Props', 'Create Animation Definition',
]); ]);
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [ assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders', 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
+182
View File
@@ -0,0 +1,182 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const zlib = require('node:zlib');
function harness() {
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
' Plugin.register(PLUGIN_ID, {', `globalThis.props = { indexPropZip, propResourcePath, openPropSource,
loadPropAsset, resolvePropModel, propTextureId, propDisplayTransform, manualPropModel,
writePropDefinition, effectivePropEntry, effectivePropItem, propEntryKey, extrudePropPixels, discoverPropSources,
buildPropMesh, refreshPropPreviews, clearPropMeshes, propFaceVertices, propDefaultUv,
applyPropPreview, createPropDialog, showPropDialog, readPropPreviews };
Plugin.register(PLUGIN_ID, {`);
const context = { Buffer, requireNativeModule: require, console, Plugin: { register() {} },
Project: { uuid: 'test' }, Group: { all: [] }, Animation: { all: [] }, localStorage: { getItem() { return null; } } };
vm.runInNewContext(source, context);
return { ...context.props, context };
}
function source(entries) {
return { read(name) { const value = entries[name]; return value == null ? null : Buffer.from(typeof value === 'object' ? JSON.stringify(value) : value); } };
}
function zipFixture(name, content, compressed = false) {
const bytes = Buffer.from(content), data = compressed ? zlib.deflateRawSync(bytes) : bytes, filename = Buffer.from(name);
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(compressed ? 8 : 0, 8);
local.writeUInt32LE(data.length, 18); local.writeUInt32LE(bytes.length, 22); local.writeUInt16LE(filename.length, 26);
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(compressed ? 8 : 0, 10);
central.writeUInt32LE(data.length, 20); central.writeUInt32LE(bytes.length, 24); central.writeUInt16LE(filename.length, 28);
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 8); end.writeUInt16LE(1, 10);
end.writeUInt32LE(central.length + filename.length, 12); end.writeUInt32LE(local.length + filename.length + data.length, 16);
return Buffer.concat([local,filename,data,central,filename,end]);
}
test('ZIP asset lookup supports stored/deflated entries and rejects corrupt/oversized archives', () => {
const h = harness(), name = 'assets/minecraft/models/item/example.json';
for (const deflate of [false,true]) assert.equal(h.indexPropZip(zipFixture(name,'{}',deflate)).read(name).toString(), '{}');
assert.throws(() => h.indexPropZip(Buffer.from('not a zip')), /Not a supported/);
const oversized = zipFixture(name,'{}');
oversized.writeUInt32LE(17000000, 30 + name.length + 2 + 24);
assert.throws(() => h.indexPropZip(oversized).read(name), /oversized/);
const broken = zipFixture(name,'{}'); broken.writeUInt32LE(0,0);
assert.throws(() => h.indexPropZip(broken).read(name), /Invalid asset entry/);
});
test('resource paths cannot escape assets and extracted sources resolve textures', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-props-'));
t.after(() => fs.rmSync(root,{ recursive:true,force:true }));
for (const id of ['minecraft:../private','../private','minecraft:item//foo','minecraft:/foo']) assert.throws(() => h.propResourcePath(id,'models','.json'));
fs.mkdirSync(path.join(root,'assets/minecraft/textures'),{recursive:true});
fs.writeFileSync(path.join(root,'assets/minecraft/textures/test.png'),'image');
assert.equal(h.openPropSource(path.join(root,'assets')).read('assets/minecraft/textures/test.png').toString(),'image');
});
test('generated models inherit textures/display and resolve modern item definitions', () => {
const h = harness();
const s = source({
'assets/minecraft/items/carrot.json': {model:{type:'minecraft:model',model:'minecraft:item/carrot'}},
'assets/minecraft/models/item/carrot.json': {parent:'minecraft:item/generated',textures:{layer0:'#actual',actual:'minecraft:item/carrot'}},
'assets/minecraft/models/item/generated.json': {parent:'builtin/generated',display:h.manualPropModel('generated').display},
'assets/minecraft/textures/item/carrot.png':'PNG',
});
const result = h.loadPropAsset(s,'minecraft:carrot','right');
assert.equal(result.parts.length,1);
assert.equal(result.parts[0].generated,true);
assert.deepEqual(Array.from(result.parts[0].transform.scale),[0.55,0.55,0.55]);
assert.equal(result.parts[0].png,'data:image/png;base64,UE5H');
});
test('left-hand and floor transforms follow Minecraft display contexts', () => {
const h = harness(), model = {display:{thirdperson_righthand:{rotation:[10,20,30],translation:[1,2,3],scale:[.4,.5,.6]},ground:{scale:[.5,.5,.5]}}};
const left = h.propDisplayTransform(model,'left');
assert.deepEqual(Array.from(left.rotation),[10,-20,-30]);
assert.deepEqual(Array.from(left.translation),[-1,2,3]);
assert.deepEqual(Array.from(h.propDisplayTransform(model,'floor').scale),[.5,.5,.5]);
const handheld = h.propDisplayTransform(h.manualPropModel('handheld'),'left');
assert.deepEqual(Array.from(handheld.rotation),[0,90,-55]);
assert.deepEqual(Array.from(handheld.translation),[0,4,0.5]);
});
test('3D JSON models keep elements, UVs, rotations and texture aliases', () => {
const h = harness(), s = source({
'assets/minecraft/models/item/test.json': {textures:{all:'minecraft:block/stone'},elements:[{from:[0,0,0],to:[16,16,16],
rotation:{origin:[8,8,8],axis:'y',angle:22.5},faces:{north:{texture:'#all',uv:[0,0,16,16]}}}]},
'assets/minecraft/textures/block/stone.png':'image',
});
const result = h.loadPropAsset(s,'minecraft:test','floor');
assert.equal(result.parts[0].elements[0].faces.north.texture,'minecraft:block/stone');
assert.equal(result.parts[0].elements[0].rotation.angle,22.5);
});
test('conditional appearances use fallback, special renderers fail clearly, cycles are rejected', () => {
const h = harness(), entries = {
'assets/minecraft/items/test.json':{model:{type:'minecraft:condition',on_false:{type:'minecraft:model',model:'minecraft:item/test'}}},
'assets/minecraft/models/item/test.json':{parent:'builtin/generated',textures:{layer0:'minecraft:item/test'}},
'assets/minecraft/textures/item/test.png':'image',
};
assert.match(h.loadPropAsset(source(entries),'minecraft:test','right').warnings[0],/default branch/);
entries['assets/minecraft/items/test.json']={model:{type:'minecraft:special'}};
assert.throws(() => h.loadPropAsset(source(entries),'minecraft:test','right'),/manual texture/);
assert.throws(() => h.resolvePropModel(source({'assets/minecraft/models/item/a.json':{parent:'item/b'},
'assets/minecraft/models/item/b.json':{parent:'item/a'}}),'item/a'),/Cyclic/);
assert.throws(() => h.propTextureId('#a',{a:'#b',b:'#a'}),/Cyclic/);
});
test('default props, stage replacements, hidden slots and inheritance preserve unrelated metadata', () => {
const h = harness(), def = {actors:[{label:'player',injector:'V'},{label:'wolf'}],stages:[{stage:1},{stage:2,use_stage:1,props:null}]};
const defaults = h.writePropDefinition(def,'player','right',0,'item','minecraft:carrot');
assert.equal(defaults.actors[0].prop_right,'minecraft:carrot');
assert.equal(def.actors[0].prop_right,undefined);
const replace = h.writePropDefinition(defaults,'player','right',1,'item','minecraft:diamond_sword');
assert.equal(replace.stages[0].props.player.prop_right,'minecraft:diamond_sword');
const hidden = h.writePropDefinition(replace,'player','right',1,'hidden','');
assert.equal(hidden.stages[0].props.player.prop_right,null);
const inherit = h.writePropDefinition(hidden,'player','right',1,'inherit','');
assert.equal(inherit.stages[0].props,undefined);
const allHidden = h.writePropDefinition(defaults,'player','right',2,'item','minecraft:carrot');
assert.equal(allHidden.stages[1].props.wolf,null);
assert.equal(allHidden.stages[1].props.player.prop_left,null);
assert.equal(allHidden.stages[1].use_stage,1);
});
test('stage preview precedence supports hidden override and default fallback', () => {
const h = harness(), entries = {};
const base = {mode:'item'}, hidden = {mode:'hidden'};
entries[h.propEntryKey('test','player','right',0)] = base;
entries[h.propEntryKey('test','player','right',2)] = hidden;
assert.equal(h.effectivePropEntry(entries,'test','player','right',1),base);
assert.equal(h.effectivePropEntry(entries,'test','player','right',2),hidden);
const def = {actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[{stage:1},
{stage:2,props:{player:{prop_right:null}}},{stage:3,props:null},{stage:4,props:{player:{prop_right:'minecraft:stick'}}}]};
assert.equal(h.effectivePropItem(def,'player','right',1),'minecraft:carrot');
assert.equal(h.effectivePropItem(def,'player','right',2),null);
assert.equal(h.effectivePropItem(def,'player','right',3),null);
assert.equal(h.effectivePropItem(def,'player','right',4),'minecraft:stick');
});
test('sprite extrusion preserves holes and omits internal boundary faces', () => {
const h = harness(), pixels = new Uint8Array(3*3*4);
for(let i=0;i<9;i++) pixels[i*4+3] = i === 4 ? 0 : 255;
const elements = h.extrudePropPixels(3,3,pixels);
assert.equal(elements.length,9);
assert.equal(elements.slice(1).reduce((sum,e)=>sum+Object.keys(e.faces).length,0),16);
assert.throws(() => h.extrudePropPixels(512,512,new Uint8Array()),/65,536/);
});
test('asset discovery reuses Asset Browser cache without requiring the plugin', (t) => {
const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-cache-'));
t.after(() => fs.rmSync(root,{recursive:true,force:true}));
fs.mkdirSync(path.join(root,'minecraft_assets_cache'));
fs.writeFileSync(path.join(root,'minecraft_assets_cache','1.21.11.jar'),'jar');
h.context.SystemInfo = {user_data_directory:root};
const sources = h.discoverPropSources();
assert.equal(sources.length,1);
assert.equal(sources[0].label,'Asset Browser: 1.21.11');
});
test('Item Props uses native dialog form fields and Apply/Cancel actions', () => {
const h = harness();
let configuration;
h.context.Animation.selected = { name: 'test.p1' };
h.context.SystemInfo = { user_data_directory: path.join(os.tmpdir(), 'missing-prop-assets') };
h.context.Dialog = class {
constructor(options) { configuration = options; }
};
h.createPropDialog();
assert.equal(configuration.title,'Item Props');
assert.equal(configuration.width,760);
assert.deepEqual(Array.from(configuration.buttons),['Cancel','Apply prop']);
assert.equal(configuration.component,undefined);
assert.equal(configuration.form.item.type,'text');
assert.equal(configuration.form.source.type,'select');
assert.equal(configuration.form.jar.type,'file');
assert.equal(configuration.form.folder.type,'folder');
assert.equal(configuration.form.texture.type,'file');
assert.equal(typeof configuration.form.item_choice.options,'function');
});
module.exports = { harness };