improvements

This commit is contained in:
2026-09-29 00:43:09 +02:00
parent 0bceb63671
commit 6d4094e7c4
2 changed files with 103 additions and 33 deletions
+55 -26
View File
@@ -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'] });
}
+48 -7
View File
@@ -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', () => {