From 2bae0c3946d91a0033796771d4da029a32ba1713 Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Mon, 28 Sep 2026 23:22:53 +0200 Subject: [PATCH] improvements --- AnimationDirector.js | 138 +++++++++++++++++++++++++------- test/automatic-import.test.js | 11 ++- test/block-requirements.test.js | 26 +++++- test/item-props.test.js | 66 ++++++++++++--- 4 files changed, 196 insertions(+), 45 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index 18efb31..80725e7 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -794,7 +794,7 @@ if (elements.length) Canvas.updateView({ elements, element_aspects: { faces: true, uv: true } }); } - function rebuildBlockRequirementGuides(animationId, entry, importedDefinition, manageUndo = true) { + function rebuildBlockRequirementGuides(animationId, entry, importedDefinition, manageUndo = true, importedProps) { const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : []; const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`; const oldGroups = Group.all.filter((group) => group.name === groupName); @@ -806,7 +806,8 @@ const createdElements = []; const createdGroups = []; if (manageUndo) Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups, - block_requirement_edit: true, animdef_edit: importedDefinition !== undefined }); + block_requirement_edit: true, animdef_edit: importedDefinition !== undefined, + prop_preview_edit: importedProps !== undefined }); if (entry) { const root = oldGroups[0] || new Group({ name: groupName, export: false }); if (!oldGroups.length) { root.init(); root.addTo('root'); } @@ -829,6 +830,7 @@ } Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated); if (importedDefinition !== undefined) saveAnimdef(animationId, importedDefinition); + if (importedProps !== undefined) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(importedProps); hideInternalBlockGuideFaces(createdElements); // Rebuilding every cube would reset forced actor materials in single-texture formats. Canvas.updateView({ elements: createdElements, groups: createdGroups, @@ -836,7 +838,7 @@ for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube); if (manageUndo) Undo.finishEdit(importedDefinition !== undefined ? 'Import animation definition' : entry ? 'Set block requirement' : 'Remove block requirement', { outliner: true, elements: createdElements, groups: createdGroups, block_requirement_edit: true, - animdef_edit: importedDefinition !== undefined }); + animdef_edit: importedDefinition !== undefined, prop_preview_edit: importedProps !== undefined }); Project.saved = false; } @@ -2738,6 +2740,11 @@ } }); definition.actors.sort((a, b) => roles.indexOf(a.label) - roles.indexOf(b.label)); + for (const stage of definition.stages) { + if (stage.props && typeof stage.props === 'object' && !Array.isArray(stage.props)) { + stage.props = Object.fromEntries(Object.entries(stage.props).map(([label, value]) => [labelMap.get(label) || label, value])); + } + } if (definition.position_anchor_actor !== undefined) { if (!labelMap.has(definition.position_anchor_actor)) throw new Error('The position anchor references an unknown actor.'); definition.position_anchor_actor = labelMap.get(definition.position_anchor_actor); @@ -2745,7 +2752,7 @@ return definition; } - function applyAnimationDefinitionImport(group, analysis, source) { + function applyAnimationDefinitionImport(group, analysis, source, sourcePaths = []) { const definition = prepareAnimationDefinitionImport(source, analysis); let entry = null; if (definition.block_requirements) { @@ -2761,8 +2768,11 @@ entry = readBlockRequirementForm(values); buildBlockRequirementGuideBoxes(entry); } + const props = importPropPreviews(group.id, definition, sourcePaths, readPropPreviews()); cancelBlockRequirementUpdate(); - rebuildBlockRequirementGuides(group.id, entry, definition); + rebuildBlockRequirementGuides(group.id, entry, definition, true, props.entries); + if (props.warnings.length) Blockbench.showQuickMessage(`Item props: ${props.warnings.join(' ')}`, 6000); + void refreshPropPreviews(true); if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(group.id); showAnimationDefinitionReviewDialog(group, analysis); } @@ -2793,7 +2803,7 @@ if (!Project || Project.uuid !== projectId) throw new Error('The active project changed. Import the definition again.'); const group = collectConjoinedAnimationGroups().groups.find((candidate) => candidate.id === result.animation_id); if (!group) throw new Error('The selected animation no longer exists.'); - applyAnimationDefinitionImport(group, analyzeAnimationDefinitionGroup(group), source); + applyAnimationDefinitionImport(group, analyzeAnimationDefinitionGroup(group), source, [files[0].path]); return true; } catch (e) { report(e); return false; } }, @@ -3951,12 +3961,6 @@ return results; } - function propItemOptions(items, search) { - const query = String(search || '').trim().toLowerCase(); - return { '': 'Select a matching item...', ...Object.fromEntries(items - .filter((id) => id.toLowerCase().includes(query)).map((id) => [id, id])) }; - } - function propJson(source, path) { const content = source.read(path); return content ? JSON.parse(content.toString('utf8').replace(/^\uFEFF/, '')) : null; @@ -4086,6 +4090,70 @@ return definition.actors?.find((entry) => entry.label === actor)?.[field]; } + function importPropPreviews(animation, definition, preferredSources = [], existing = {}) { + const entries = Object.fromEntries(Object.entries(existing).filter(([, entry]) => entry?.animation !== animation)); + const warnings = [], requested = []; + for (const actor of definition.actors || []) for (const slot of Object.keys(PROP_SLOTS)) { + const base = effectivePropItem(definition, actor.label, slot, 0); + if (typeof base === 'string') requested.push({ actor: actor.label, slot, stage: 0, item: base }); + for (const stage of definition.stages || []) { + const item = effectivePropItem(definition, actor.label, slot, stage.stage); + if (item === base) continue; + if (typeof item === 'string' || item === null && typeof base === 'string') { + requested.push({ actor: actor.label, slot, stage: stage.stage, item }); + } + } + } + if (!requested.length) return { entries, warnings }; + let io; + try { io = desktopFiles(); } + catch (_) { warnings.push('Preview assets require desktop Blockbench. Prop IDs remain in the imported definition.'); return { entries, warnings }; } + const paths = new Set(); + const addPath = (path) => { + if (!path) return; + try { + const resolved = importFilePath(path, io); + if (io.fs.statSync(resolved).isDirectory() || /\.(?:jar|zip)$/i.test(resolved)) paths.add(resolved); + else paths.add(findImportPack(resolved, io).root); + } catch (_) { /* A standalone definition may have no parent pack. */ } + }; + preferredSources.forEach(addPath); + try { savedPackFolders().forEach(addPath); } catch (_) { /* No registered packs. */ } + try { addPath(localStorage.getItem('animationdirector_prop_source')); } catch (_) { /* No saved source. */ } + try { discoverPropSources(io).forEach((source) => addPath(source.file)); } catch (_) { /* No client assets found. */ } + const sources = []; + for (const path of paths) { + try { sources.push({ path, data: openPropSource(path, io) }); } + catch (_) { /* Try the next available source. */ } + } + const resolved = new Map(), missing = new Set(); + for (const prop of requested) { + const key = propEntryKey(animation, prop.actor, prop.slot, prop.stage); + if (prop.item === null) { + entries[key] = { animation, actor: prop.actor, slot: prop.slot, stage: prop.stage, mode: 'hidden' }; + continue; + } + const assetKey = `${prop.slot}:${prop.item}`; + if (!resolved.has(assetKey)) { + let found = null; + for (const source of sources) { + try { found = { source: source.path, asset: loadPropAsset(source.data, prop.item, prop.slot) }; break; } + catch (_) { /* Item resources may live in another source. */ } + } + resolved.set(assetKey, found); + } + const found = resolved.get(assetKey); + if (!found) { missing.add(prop.item); continue; } + entries[key] = { animation, actor: prop.actor, slot: prop.slot, stage: prop.stage, mode: 'item', + item: prop.item, source: found.source, texture: '', preset: 'generated', asset: found.asset }; + if (JSON.stringify(entries).length > 24 * 1024 * 1024) { + delete entries[key]; missing.add(prop.item); + } + } + if (missing.size) warnings.push(`No preview for ${[...missing].join(', ')}; choose an asset source in Item Props. The animdef items were preserved.`); + return { entries, warnings }; + } + 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.'); @@ -4324,7 +4392,7 @@ 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: '', + actor: '', slot: 'right', stage: '0', source: '', jar: '', folder: '', choice: '', actors: [], stages: [], sources: discoverPropSources(), items: [], busy: false, }; const addSource = (file) => { @@ -4390,11 +4458,10 @@ 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 }, + description: 'The in-game item saved to the animdef. You can also type an ID not listed below.', + condition: (result) => result.mode === 'item' }, item_choice: { label: 'Choose item', type: 'select', value: '', - options: () => propItemOptions(state.items, state.search), + options: () => ({ '': 'Select an item...', ...Object.fromEntries(state.items.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' }, @@ -4409,29 +4476,38 @@ onFormChange(result) { const nextSource = result.jar && result.jar !== state.jar ? result.jar : result.folder && result.folder !== state.folder ? result.folder : result.source; + const sourceChanged = nextSource !== state.source; + const targetChanged = result.animation !== state.animation || result.actor !== state.actor + || result.slot !== state.slot || result.stage !== state.stage; state.jar = result.jar; state.folder = result.folder; - if (nextSource !== state.source) { + if (sourceChanged) { state.source = nextSource; addSource(nextSource); - this.form.setValues({ source: nextSource, item_choice: '' }, false); state.choice = ''; + this.form.setValues({ source: nextSource, item_choice: '' }, false); 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); } + state.choice = ''; + this.form.setValues({ actor: state.actor, stage: state.stage, source: state.source, item_choice: '', ...entry }, false); + if (state.source !== previousSource) { + try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); } + state.choice = ''; + this.form.setValues({ item_choice: '' }, false); + } } 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) { + this.form.setValues({ source: state.source, item_choice: '', ...entry }, false); + if (state.source !== previousSource) { + try { indexItems(); } catch (error) { state.items = []; Blockbench.showQuickMessage(error.message, 3500); } + state.choice = ''; + this.form.setValues({ item_choice: '' }, false); + } + } + if (!sourceChanged && !targetChanged && result.item_choice && result.item_choice !== state.choice) { state.choice = result.item_choice; this.form.setValues({ item: result.item_choice }, false); } @@ -4850,6 +4926,7 @@ setProjectAnimdefAnimators(plan.definition.animators || []); Animator.loadFile({ name: `${plan.parsed.animationId}.animation.json`, path: '', content: JSON.stringify(plan.imported.fileObj) }, plan.imported.stageNames); rebuildBlockRequirementGuides(plan.parsed.animationId, plan.blockEntry, plan.definition, false); + if (plan.propEntries) Project[PROP_PREVIEWS_PROPERTY] = JSON.stringify(plan.propEntries); Canvas.updateAll(); applyAllActorTexturePreviews(false); refreshBlockGuideFaces(); @@ -4858,6 +4935,7 @@ Undo.finishEdit('Automatically import animation', finished); Project.saved = false; if (Animation.all[0]) Animation.all[0].select(); + if (plan.propEntries) void refreshPropPreviews(true); if (blockRequirementPanel?.vue) blockRequirementPanel.vue.load(plan.parsed.animationId); Blockbench.showQuickMessage(`Imported ${plan.parsed.animationId}: ${plan.rows.length} actors, ${plan.imported.stageNames.length} stages.`, 4000); } catch (error) { @@ -4982,10 +5060,12 @@ blockEntry = readBlockRequirementForm(Object.fromEntries(Object.entries(fields).filter(([, value]) => value.type !== 'info').map(([key, value]) => [key, value.value]))); buildBlockRequirementGuideBoxes(blockEntry); } + const props = importPropPreviews(parsed.animationId, prepared, registry.packs.map((pack) => pack.root)); + warnings.push(...props.warnings); const review = { info: { type: 'info', text: `### Import ${parsed.animationId}\n${rows.map((row) => `${row.label}: ${row.resolved.kind}\n${row.resolved.file}`).join('\n\n')}\n\n${warnings.join('\n')}` } }; const accepted = await importDialog('Automatic Import: Review', review, 'Create Project and Import'); if (!accepted) return; - commitAutomaticImport({ parsed, rows, definition: prepared, imported, blockEntry }); + commitAutomaticImport({ parsed, rows, definition: prepared, imported, blockEntry, propEntries: props.entries }); localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(registry.packs.map((pack) => pack.root))); } catch (error) { Blockbench.showMessageBox({ title: 'Automatically Import Animation', message: error.message, buttons: ['OK'] }); diff --git a/test/automatic-import.test.js b/test/automatic-import.test.js index b64ef3f..fb7784e 100644 --- a/test/automatic-import.test.js +++ b/test/automatic-import.test.js @@ -30,6 +30,7 @@ function harness(capture = false) { add(undo) { assert.equal(undo, false); Texture.all.push(this); } } const context = { + Buffer, Timeline: { animation: null }, Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] }, Plugin: { register() {} }, requireNativeModule: require, localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) }, @@ -234,7 +235,8 @@ test('short clip names are inferred only for unambiguous actors; repeated entiti test('complete import prepares all stages, actor models, local textures and definition fields without touching the current project', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); - const def = { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf', 'minecraft:fox'], actor_tags: ['gender.male'] }], + const def = { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf', 'minecraft:fox'], actor_tags: ['gender.male'], + prop_right: 'minecraft:carrot' }], animators: ['Creator'], content_tags: ['example'], stages: [{ stage: 1, non_peak: true }, { stage: 2 }] }; const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), def); f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), { animations: { @@ -243,6 +245,10 @@ test('complete import prepares all stages, actor models, local textures and defi } }); f.model(source, 'test:source/entity/wolf.m', { afw_texture: 'test:textures/wolf.png' }); f.write(path.join(source.root, 'assets/test/textures/wolf.png'), 'local image'); + f.write(path.join(source.root, 'assets/minecraft/items/carrot.json'), { model: { type: 'minecraft:model', model: 'minecraft:item/carrot' } }); + f.write(path.join(source.root, 'assets/minecraft/models/item/carrot.json'), { + parent: 'minecraft:builtin/generated', textures: { layer0: 'minecraft:item/carrot' } }); + f.write(path.join(source.root, 'assets/minecraft/textures/item/carrot.png'), 'image'); h.context.selectedFile = { path: defFile }; h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path; await h.automaticallyImportAnimation(); @@ -260,6 +266,7 @@ test('complete import prepares all stages, actor models, local textures and defi assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1', 'example.p2']); assert.ok(plan.imported.fileObj.animations['example.p2'].bones.actor1_body); assert.equal(plan.definition.actors[0].label, 'wolf'); + assert.equal(Object.values(plan.propEntries)[0].item, 'minecraft:carrot'); assert.equal(plan.definition.stages[1].non_peak, undefined); assert.equal(h.undo.length, 0); assert.ok(h.storage.get('animationdirector_pack_folders').includes(source.root.replace(/\\/g, '\\\\'))); @@ -380,6 +387,7 @@ test('missing models can be supplied manually, and their locally supplied textur test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => { const h = harness(); const plan = { parsed: { animationId: 'example' }, blockEntry: null, + propEntries: { '["example","wolf","right",0]': { animation: 'example', item: 'minecraft:carrot', mode: 'item' } }, definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] }, imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': { bones: { actor1_body: { rotation: [1, 2, 3] } } } } } }, rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } }, @@ -394,6 +402,7 @@ test('batch import uses a new project, one undo entry, actor prefixes and textur const override = JSON.parse(h.context.Project.multiactor_bone_texture_overrides); assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png'); assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example); + assert.equal(JSON.parse(h.context.Project.animationdirector_prop_previews)['["example","wolf","right",0]'].item,'minecraft:carrot'); }); test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => { diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js index 13210b8..2db6687 100644 --- a/test/block-requirements.test.js +++ b/test/block-requirements.test.js @@ -1,5 +1,6 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); @@ -83,7 +84,7 @@ function harness() { on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); }, addCSS() { return { delete() {} }; }, showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } }, - console, + Buffer, requireNativeModule: require, console, }; vm.runInNewContext(source, context); return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, previewUpdates, menus, Group, Cube }; @@ -173,13 +174,15 @@ test('import prefills generator fields, builds guides and retains fields without test('import maps explicit labels before positional fallbacks and updates actor references', () => { const h = harness(); const source = { actors: [{ label: 'wolf', injector: 'V', injects_into: 'bottom' }, - { label: 'bottom', receiver: true }], stages: [{ stage: 1 }, { stage: 2 }], position_anchor_actor: 'bottom' }; + { label: 'bottom', receiver: true }], stages: [{ stage: 1, props: { bottom: { prop_right: 'minecraft:carrot' } } }, + { stage: 2 }], position_anchor_actor: 'bottom' }; const definition = h.prepareAnimationDefinitionImport(source, importAnalysis()); assert.equal(definition.actors[0].label, 'player'); assert.equal(definition.actors[0].receiver, true); assert.equal(definition.actors[1].label, 'wolf'); assert.deepEqual(Array.from(definition.actors[1].injects_into), ['player']); assert.equal(definition.position_anchor_actor, 'player'); + assert.equal(definition.stages[0].props.player.prop_right, 'minecraft:carrot'); assert.equal(source.actors[1].label, 'bottom', 'Input JSON is not mutated'); const positional = h.prepareAnimationDefinitionImport({ actors: [{ receiver: true }, { injector: 'V' }], stages: [{ stage: 1 }, { stage: 2 }] }, importAnalysis()); @@ -187,6 +190,25 @@ test('import maps explicit labels before positional fallbacks and updates actor assert.equal(positional.actors[1].label, 'wolf'); }); +test('manual definition import embeds item prop previews in the same undoable edit', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-manual-props-')); + t.after(() => fs.rmSync(root,{recursive:true,force:true})); + const write=(relative,data)=>{ const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true}); + fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data)); }; + write('assets/minecraft/items/carrot.json',{model:{type:'minecraft:model',model:'minecraft:item/carrot'}}); + write('assets/minecraft/models/item/carrot.json',{parent:'minecraft:builtin/generated',textures:{layer0:'minecraft:item/carrot'}}); + write('assets/minecraft/textures/item/carrot.png','image'); + h.applyAnimationDefinitionImport({id:'test'},importAnalysis(),{ + actors:[{label:'player',prop_right:'minecraft:carrot'},{label:'wolf'}], + stages:[{stage:1},{stage:2,props:{player:{prop_right:null}}}], + },[root]); + const previews=JSON.parse(h.project.animationdirector_prop_previews); + assert.equal(previews['["test","player","right",0]'].item,'minecraft:carrot'); + assert.equal(previews['["test","player","right",2]'].mode,'hidden'); + assert.equal(h.edits.at(-1).aspects.prop_preview_edit,true); + assert.equal(h.edits.at(-1).post.prop_preview_edit,true); +}); + test('invalid imports leave definitions and guides unchanged', () => { const h = harness(); h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm())); diff --git a/test/item-props.test.js b/test/item-props.test.js index 1d75b3a..fd4b511 100644 --- a/test/item-props.test.js +++ b/test/item-props.test.js @@ -11,8 +11,8 @@ function harness() { ' 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, propItemOptions, - applyPropPreview, createPropDialog, showPropDialog, readPropPreviews }; + buildPropMesh, refreshPropPreviews, clearPropMeshes, propFaceVertices, propDefaultUv, + applyPropPreview, createPropDialog, showPropDialog, readPropPreviews, importPropPreviews }; Plugin.register(PLUGIN_ID, {`); const context = { Buffer, requireNativeModule: require, console, Plugin: { register() {} }, Project: { uuid: 'test' }, Group: { all: [] }, Animation: { all: [] }, localStorage: { getItem() { return null; } } }; @@ -161,16 +161,6 @@ test('asset discovery reuses Asset Browser cache without requiring the plugin', assert.equal(sources[0].label,'Asset Browser: 1.21.11'); }); -test('item picker includes entries after the first hundred and filters the full source list', () => { - const h = harness(); - const items = Array.from({ length: 1505 }, (_, index) => `minecraft:item_${String(index).padStart(4, '0')}`); - const all = h.propItemOptions(items, ''); - assert.equal(Object.keys(all).length, 1506); - assert.equal(all['minecraft:item_1504'], 'minecraft:item_1504'); - const searched = h.propItemOptions(items, ' ITEM_1504 '); - assert.deepEqual(Object.keys(searched), ['', 'minecraft:item_1504']); -}); - test('Item Props uses native dialog form fields and Apply/Cancel actions', () => { const h = harness(); let configuration; @@ -189,7 +179,57 @@ test('Item Props uses native dialog form fields and Apply/Cancel actions', () => 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'); + assert.equal(configuration.form.item_search,undefined); + assert.equal(configuration.form.item_choice.type,'select'); +}); + +test('item dropdown includes the entire asset source alongside editable Item ID', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-item-picker-')); + t.after(() => fs.rmSync(root,{recursive:true,force:true})); + const items = path.join(root,'assets','minecraft','items'); + fs.mkdirSync(items,{recursive:true}); + for(let index=0;index<105;index++) fs.writeFileSync(path.join(items,`item_${String(index).padStart(3,'0')}.json`),'{}'); + h.context.Animation.selected = { name:'test.p1' }; + h.context.localStorage = { getItem() { return root; } }; + let configuration; + h.context.Dialog = class { + constructor(options){ configuration=options; } + }; + h.createPropDialog(); + const options = configuration.form.item_choice.options(); + assert.equal(Object.keys(options).length,106); + assert.equal(options['minecraft:item_104'],'minecraft:item_104'); + assert.equal(configuration.form.item.value,'minecraft:carrot'); +}); + +test('imported definitions create default and stage item previews without losing hidden overrides', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(),'ad-imported-props-')); + t.after(() => fs.rmSync(root,{recursive:true,force:true})); + for (const item of ['carrot','stick']) { + const write = (relative,data) => { + const file=path.join(root,relative); fs.mkdirSync(path.dirname(file),{recursive:true}); + fs.writeFileSync(file,typeof data==='string'?data:JSON.stringify(data)); + }; + write(`assets/minecraft/items/${item}.json`,{model:{type:'minecraft:model',model:`minecraft:item/${item}`}}); + write(`assets/minecraft/models/item/${item}.json`,{parent:'minecraft:builtin/generated',textures:{layer0:`minecraft:item/${item}`}}); + write(`assets/minecraft/textures/item/${item}.png`,'png bytes'); + } + const definition={actors:[{label:'player',prop_right:'minecraft:carrot'}],stages:[ + {stage:1},{stage:2,props:{player:{prop_right:'minecraft:stick'}}},{stage:3,props:{player:{prop_right:null}}}]}; + const oldKey=h.propEntryKey('other','player','left',0); + const result=h.importPropPreviews('example',definition,[root],{[oldKey]:{animation:'other'}}); + assert.equal(result.warnings.length,0); + assert.equal(result.entries[oldKey].animation,'other'); + const get=(stage)=>result.entries[h.propEntryKey('example','player','right',stage)]; + assert.equal(get(0).item,'minecraft:carrot'); + assert.equal(get(0).asset.parts[0].generated,true); + assert.equal(get(1),undefined); + assert.equal(get(2).item,'minecraft:stick'); + assert.equal(get(3).mode,'hidden'); + const missing=h.importPropPreviews('example',{actors:[{label:'player',prop_left:'minecraft:missing'}],stages:[{stage:1}]},[root],result.entries); + assert.match(missing.warnings[0],/minecraft:missing/); + assert.equal(missing.entries[h.propEntryKey('example','player','left',0)],undefined); + assert.equal(missing.entries[oldKey].animation,'other'); }); module.exports = { harness };