From 6d4094e7c428f15ec939fb75ff8678d53696ebba Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 00:43:09 +0200 Subject: [PATCH] improvements --- AnimationDirector.js | 81 ++++++++++++++++++++++++----------- test/automatic-import.test.js | 55 +++++++++++++++++++++--- 2 files changed, 103 insertions(+), 33 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index 38145e8..6c24fa9 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -4978,9 +4978,10 @@ [id, `${id}/${id.split('/').pop()}`], '.animation.json') }; } - function importDialog(title, form, confirmText = 'Continue') { + function importDialog(title, form, confirmText = 'Continue', onFormChange = null) { return new Promise((resolve) => new Dialog({ id: 'animationdirector_automatic_import_step', title, width: 680, form, buttons: ['Cancel', confirmText], cancelIndex: 0, confirmIndex: 1, + ...(onFormChange ? { onFormChange } : {}), onConfirm(result) { resolve(result); }, onCancel() { resolve(null); } }).show()); } @@ -5036,15 +5037,14 @@ function loadImportTextures(row, packs, io) { const candidates = actorTextureCandidates(row.json, row.geo, row.resolved.file, row.entity, row.slim); - const ids = new Set([...candidates, ...Object.values(row.json.afw_bone_textures || {}) - .filter((value) => typeof value === 'string')]); row.textures = new Map(); + row.defaultTexture = undefined; let cachedSources; let modelPack; try { modelPack = findImportPack(row.resolved.file, io); } catch (_) { /* Standalone model. */ } const searchPacks = modelPack && !packs.some((pack) => pack.root === modelPack.root) ? [modelPack, ...packs] : packs; - for (const resource of ids) { + const resolveTexture = (resource) => { const id = resource.includes(':') ? resource : `minecraft:${resource}`; const texture = findImportResource(searchPacks, id, '', '', io); if (texture) { @@ -5057,8 +5057,7 @@ dimensions = importTextureDimensions(bytes, metadata); } } catch (_) { /* Let Blockbench report unreadable texture files. */ } - row.textures.set(resource, { ...texture, dimensions }); - continue; + return { ...texture, dimensions }; } if (!cachedSources) { cachedSources = []; @@ -5067,7 +5066,7 @@ } } const parsed = importResourceId(id); - if (!parsed) continue; + if (!parsed) return null; const relative = `assets/${parsed.namespace}/${parsed.resource}`; for (const source of cachedSources) { let bytes; @@ -5077,11 +5076,22 @@ if (!dimensions) continue; try { dimensions = importTextureDimensions(bytes, propJson(source, `${relative}.mcmeta`)); } catch (_) { /* Keep the PNG dimensions when metadata is invalid. */ } - row.textures.set(resource, { bytes, relative, dimensions }); - break; + return { bytes, relative, dimensions }; } + return null; + }; + for (const resource of candidates) { + const texture = resolveTexture(resource); + if (!texture) continue; + row.textures.set(resource, texture); + row.defaultTexture = resource; + break; + } + for (const resource of Object.values(row.json.afw_bone_textures || {})) { + if (typeof resource !== 'string' || row.textures.has(resource)) continue; + const texture = resolveTexture(resource); + if (texture) row.textures.set(resource, texture); } - row.defaultTexture = candidates.find((id) => row.textures.has(id)); } function commitAutomaticImport(plan) { @@ -5187,7 +5197,10 @@ const available = parsed.actorLabels.find((candidate) => !usedClips.has(candidate)); if (available) { clipRoles[index] = available; usedClips.add(available); } }); - const form = { info: { type: 'info', text: `### ${companions.id}\nImport into a NEW GeckoLib project. Choose concrete actors and confirm their clip roles. Use Manual Import > Pack Folders to register shared/default packs before importing.\nFolder priority: ${registry.packs.map((pack) => pack.name).join(' > ')}` } }; + const form = { + info: { type: 'info', text: `### ${companions.id}\nImport into a NEW GeckoLib project. Use Manual Import > Pack Folders to register shared/default packs before importing.\nFolder priority: ${registry.packs.map((pack) => pack.name).join(' > ')}` }, + actors_section: { type: 'info', text: '### Actors\nChoose concrete actors and confirm their animation roles.' }, + }; definition.actors.forEach((actor, index) => { const entities = (actor.entity_types || []).filter((id) => importResourceId(id)); form[`heading_${index}`] = { type: 'info', text: `### Actor ${index + 1}: ${keys[index]}` }; @@ -5205,7 +5218,28 @@ if (!entities.length || entities.includes('minecraft:player')) form[`slim_${index}`] = { type: 'checkbox', label: 'Slim player model', value: false, condition: (result) => result[`entity_${index}`] === 'minecraft:player' }; }); - const choices = await importDialog('Automatic Import: Actors', form); + form.models_section = { type: 'info', text: '### Actor Models\nModels are selected automatically from the actor choices above, following local, imported, old default revision, and shared priority. Select a file only to override that lookup or supply a missing model.' }; + const modelStatus = (index, values) => { + const entity = values[`entity_${index}`], gender = values[`gender_${index}`]; + if (!importResourceId(entity) || !['m', 'f', 'mf'].includes(gender)) return 'Choose an entity and model gender above.'; + try { + const resolved = resolveImportModel(source, registry.packs, entity, gender, + definition.actors[index].entity_variant, io, !!values[`slim_${index}`]); + return resolved ? `${resolved.kind}: ${resolved.file}` : 'No automatic model found. Select a model file below.'; + } catch (error) { return `Model lookup unavailable: ${error.message}`; } + }; + const defaults = Object.fromEntries(Object.entries(form).filter(([, field]) => 'value' in field) + .map(([key, field]) => [key, field.value])); + definition.actors.forEach((_, index) => { + form[`source_${index}`] = { type: 'text', label: `Actor ${index + 1} automatic model`, + value: modelStatus(index, defaults), readonly: true, full_width: true }; + form[`model_${index}`] = { type: 'file', label: `Actor ${index + 1} model override`, + return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' }; + }); + const choices = await importDialog('Automatic Import: Actors & Models', form, 'Create Project and Import', function(result) { + this.form.setValues(Object.fromEntries(definition.actors.map((_, index) => + [`source_${index}`, modelStatus(index, result)])), false); + }); if (!choices) return; const rows = definition.actors.map((actor, index) => ({ actor, label: choices[`clip_${index}`], entity: choices[`entity_${index}`], gender: choices[`gender_${index}`], slim: !!choices[`slim_${index}`] })); if (new Set(rows.map((row) => row.label)).size !== rows.length || rows.some((row) => !parsed.actorLabels.includes(row.label))) { @@ -5244,18 +5278,11 @@ const prepared = prepareAnimationDefinitionImport(relabeled, analysis); const warnings = registry.warnings.slice(); if (ignoredLabels.length) warnings.push(`Ignored animation actors not mapped to the definition: ${ignoredLabels.join(', ')}.`); - const models = { info: { type: 'info', text: '### Actor models\nModels follow local, declared imports, old default revisions, and shared fallbacks. Choose a replacement only when necessary; missing models must be selected manually.' } }; - rows.forEach((row, index) => { - row.resolved = resolveImportModel(source, registry.packs, row.entity, row.gender, row.actor.entity_variant, io, row.slim); - models[`source_${index}`] = { type: 'info', text: `### ${row.label}\n${row.resolved ? `${row.resolved.kind}: ${row.resolved.file}` : 'Model not found. Select a geometry file below.'}` }; - models[`model_${index}`] = { type: 'file', label: row.resolved ? 'Override model (optional)' : 'Select model (required)', - return_as: 'file', extensions: ['json'], readtype: 'text', filetype: 'GeckoLib geometry' }; - }); - const modelChoices = await importDialog('Automatic Import: Models', models); - if (!modelChoices) return; for (const [index, row] of rows.entries()) { - if (modelChoices[`model_${index}`]?.path) row.resolved = { file: modelChoices[`model_${index}`].path, kind: 'Manually selected' }; - if (!row.resolved) throw new Error(`Select a model for ${row.label}.`); + const override = choices[`model_${index}`]; + row.resolved = override?.path ? { file: override.path, kind: 'Manually selected' } + : resolveImportModel(source, registry.packs, row.entity, row.gender, row.actor.entity_variant, io, row.slim); + if (!row.resolved) throw new Error(`No model found for ${row.label}. Select a model override for actor ${index + 1} and try again.`); row.json = readImportJson(row.resolved.file, io); row.geo = validateImportGeometry(row.json); loadImportTextures(row, registry.packs, io); @@ -5274,11 +5301,13 @@ } 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, propEntries: props.entries }); localStorage.setItem(PACK_FOLDERS_KEY, JSON.stringify(registry.packs.map((pack) => pack.root))); + if (warnings.length) { + console.warn(`[AnimationDirector] Import notes for ${parsed.animationId}:\n${warnings.join('\n')}`); + const more = warnings.length > 1 ? ` (+${warnings.length - 1} more in Blockbench console)` : ''; + Blockbench.showQuickMessage(`Imported with notes: ${warnings[0]}${more}`, 6000); + } } 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 376063b..16c9ad1 100644 --- a/test/automatic-import.test.js +++ b/test/automatic-import.test.js @@ -258,11 +258,22 @@ test('complete import prepares all stages, actor models, local textures and defi const plan = h.context.importedPlan; assert.equal(plan.rows[0].resolved.kind, 'Local'); assert.equal(plan.rows[0].entity, 'minecraft:wolf'); - const actorForm = h.dialogs.find((dialog) => dialog.title.endsWith('Actors')).form; + const importDialog = h.dialogs.find((dialog) => dialog.title.endsWith('Actors & Models')); + const actorForm = importDialog.form; assert.equal(actorForm.entity_0.type, 'select'); assert.equal(actorForm.entity_0.value, 'minecraft:wolf'); assert.equal(actorForm.entity_0.options['minecraft:fox'], 'minecraft:fox'); assert.equal(actorForm.gender_0.value, 'm'); + assert.equal(actorForm.models_section.type, 'info'); + assert.match(actorForm.source_0.value, /Local:.*wolf\.m\.geo\.json/); + assert.equal(actorForm.model_0.type, 'file'); + assert.equal(h.dialogs.length, 1, 'Import needs only one configuration dialog'); + let updated; + importDialog.onFormChange.call({ form: { setValues(values, rerender) { + assert.equal(rerender, false); + updated = values; + } } }, { entity_0: 'minecraft:fox', gender_0: 'f', slim_0: false }); + assert.match(updated.source_0, /No automatic model found/); assert.equal(plan.rows[0].textures.size, 1); assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1', 'example.p2']); assert.ok(plan.imported.fileObj.animations['example.p2'].bones.actor1_body); @@ -306,6 +317,36 @@ test('complete import finds model texture in cached assets and uses its PNG dime assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture); }); +test('alternative vanilla texture paths do not add unused textures unless a bone references them', (t) => { + const f = fixtures(t), h = harness(), source = f.makePack('source'); + const model = f.model(source, 'test:source/entity/silverfish.m'); + const cache = path.join(f.root, 'cached-assets'); + const png = Buffer.alloc(24); + Buffer.from('89504e470d0a1a0a', 'hex').copy(png); + png.writeUInt32BE(64, 16); png.writeUInt32BE(32, 20); + for (const relative of ['silverfish.png', 'silverfish/silverfish.png']) { + const file = path.join(cache, 'assets/minecraft/textures/entity', relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, png); + } + h.storage.set('animationdirector_prop_source', cache); + const json = JSON.parse(fs.readFileSync(model, 'utf8')); + const row = { json, geo: json['minecraft:geometry'][0], entity: 'minecraft:silverfish', slim: false, + label: 'silverfish', resolved: { file: model } }; + h.loadImportTextures(row, [source], io); + assert.equal(row.defaultTexture, 'minecraft:textures/entity/silverfish/silverfish.png'); + assert.deepEqual(Array.from(row.textures.keys()), [row.defaultTexture]); + + const plan = { parsed: { animationId: 'example' }, rows: [row], definition: { actors: [{}] }, + imported: { stageNames: [], fileObj: { animations: {} } }, blockEntry: null }; + h.commitAutomaticImport(plan); + assert.equal(h.context.Texture.all.length, 1); + + row.json.afw_bone_textures = { body: 'minecraft:textures/entity/silverfish.png' }; + h.loadImportTextures(row, [source], io); + assert.deepEqual(Array.from(row.textures.keys()), [row.defaultTexture, 'minecraft:textures/entity/silverfish.png']); +}); + test('complete import uses the matching Alex skin as a player-only texture fallback', async (t) => { const f = fixtures(t), source = f.makePack('source'); const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { @@ -329,7 +370,7 @@ test('complete import uses the matching Alex skin as a player-only texture fallb const h = harness(true); h.storage.set('animationdirector_prop_source', cache); h.context.selectedFile = { path: defFile }; - h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') + h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models') ? { ...result, slim_0: slim } : result; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); @@ -352,7 +393,7 @@ test('definition actors exclude extra clips, their effects and stages containing } }); f.model(source, 'test:source/entity/wolf.m'); h.context.selectedFile = { path: file }; - h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') ? { ...result, gender_0: 'm' } : result; + h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models') ? { ...result, gender_0: 'm' } : result; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); const plan = h.context.importedPlan; @@ -364,7 +405,7 @@ test('definition actors exclude extra clips, their effects and stages containing assert.deepEqual(Object.keys(clip.bones), ['actor1_body']); assert.equal(clip.sound_effects, undefined); assert.equal(clip.particle_effects, undefined); - assert.match(h.dialogs.at(-1).form.info.text, /Ignored animation actors.*unused/); + assert.ok(h.messages.some((message) => /Ignored animation actors.*unused/.test(message))); }); test('a definition actor without a distinct clip still requires a valid mapping', async (t) => { @@ -442,13 +483,13 @@ test('missing models can be supplied manually, and their locally supplied textur }); const model = f.model(source, 'test:unrelated/wolf.m', { afw_texture: 'test:textures/unavailable.png' }); h.context.selectedFile = { path: file }; - h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') ? { ...result, gender_0: 'm' } - : dialog.title.endsWith('Models') ? { ...result, model_0: { path: model } } : result; + h.context.answer = (dialog, result) => dialog.title.endsWith('Actors & Models') + ? { ...result, gender_0: 'm', model_0: { path: model } } : result; await h.automaticallyImportAnimation(); assert.deepEqual(h.errors, []); assert.equal(h.context.importedPlan.rows[0].resolved.kind, 'Manually selected'); assert.equal(h.context.importedPlan.rows[0].textures.size, 0); - assert.match(h.dialogs.at(-1).form.info.text, /no base texture found locally/); + assert.ok(h.messages.some((message) => /Imported with notes:.*no base texture/.test(message))); }); test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => {