diff --git a/AnimationDirector.js b/AnimationDirector.js index f64b4ae..724dbfa 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -18,6 +18,12 @@ let importAnimationDefinitionAction; let automaticImportAction; 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 = { '': 'None (normal animation)', manualpeak: 'Manual peak', @@ -3817,7 +3823,647 @@ // Automatic import reads only explicitly registered, extracted packs. No network access. const PACK_FOLDERS_KEY = 'animationdirector_pack_folders'; 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() { 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: '{}', }); } + if (!ModelProject.properties[PROP_PREVIEWS_PROPERTY]) { + propProjectProperty = new Property(ModelProject, 'string', PROP_PREVIEWS_PROPERTY, { default: '{}' }); + } } 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 }) => { 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.prop_preview_edit) save[PROP_PREVIEWS_PROPERTY] = Project[PROP_PREVIEWS_PROPERTY] || '{}'; if (aspects.automatic_animation_import) { 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]; } scheduleActorTexturePreviewApply(); + void refreshPropPreviews(true); }; Blockbench.on('create_undo_save', blockRequirementUndoSaveListener); 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', { name: 'Block Requirements', @@ -4516,6 +5176,7 @@ You can import or export complete AFW animation IDs as one file containing all s replaceActorAction, exportConjoinedAction, blockRequirementAction, + propAction, createAnimationDefinitionAction, ], }); @@ -4529,6 +5190,10 @@ You can import or export complete AFW animation IDs as one file containing all s MenuBar.update(); }, 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(); if (actorTexturePreviewProjectListener) { for (const eventName of ACTOR_TEXTURE_PREVIEW_EVENTS) { diff --git a/test/animdef-generator.test.js b/test/animdef-generator.test.js index fe5a8c3..d93f63e 100644 --- a/test/animdef-generator.test.js +++ b/test/animdef-generator.test.js @@ -69,6 +69,24 @@ test('metadata-only playback stages remain editable and survive definition expor 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', () => { const harness = createHarness(); const dialog = harness.open(); diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js index 32ae3f1..13210b8 100644 --- a/test/block-requirements.test.js +++ b/test/block-requirements.test.js @@ -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'); assert.deepEqual(Array.from(menu.children, (action) => action.name), [ '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), [ 'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders', diff --git a/test/item-props.test.js b/test/item-props.test.js new file mode 100644 index 0000000..94343f3 --- /dev/null +++ b/test/item-props.test.js @@ -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 };