From 8d1346986b105b33717de60b04ec76d75d9730be Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Mon, 28 Sep 2026 21:55:40 +0200 Subject: [PATCH] improved and fixed stuff --- AnimationDirector.js | 40 ++++++++++++++++++++++++++++++++-- test/animdef-generator.test.js | 19 +++++++++++++++- test/automatic-import.test.js | 35 ++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/AnimationDirector.js b/AnimationDirector.js index e3dfc0e..f64b4ae 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -2686,12 +2686,16 @@ const stageNumbers = new Set(analysis.stages.map((stage) => stage.stageNumber)); const seenStages = new Set(); for (const stage of definition.stages) { - if (!isObject(stage) || !Number.isInteger(stage.stage) || !stageNumbers.has(stage.stage) || seenStages.has(stage.stage)) { + if (!isObject(stage) || !Number.isInteger(stage.stage) || stage.stage < 1 || seenStages.has(stage.stage)) { throw new Error('Imported stages must have unique stage numbers matching the target animation.'); } + const playback = stage.use_stage ?? stage.stage; + if (!Number.isInteger(playback) || !stageNumbers.has(playback)) { + throw new Error(`Stage p${stage.stage} requires animation clips for playback stage p${playback}.`); + } seenStages.add(stage.stage); } - if (seenStages.size !== stageNumbers.size) throw new Error('The definition must include every stage of the target animation.'); + if ([...stageNumbers].some((number) => !seenStages.has(number))) throw new Error('The definition must include every stage of the target animation.'); const roles = analysis.actorIndices.map((index) => analysis.actorLabels.get(index)); const labels = new Set(); @@ -2797,6 +2801,11 @@ const saved = getSavedAnimdef(group.id); const savedActors = new Map((Array.isArray(saved.actors) ? saved.actors : []).map((actor) => [actor.label, actor])); const savedStages = new Map((Array.isArray(saved.stages) ? saved.stages : []).map((stage) => [stage.stage, stage])); + const editableStages = new Map(analysis.stages.map((stage) => [stage.stageNumber, stage])); + const reusedStages = [...savedStages.values()].filter((stage) => !editableStages.has(stage.stage) + && editableStages.has(stage.use_stage)).map((stage) => ({ ...editableStages.get(stage.use_stage), + stageNumber: stage.stage, loop: stage.loop ?? editableStages.get(stage.use_stage).loop })); + analysis = { ...analysis, stages: [...analysis.stages, ...reusedStages].sort((a, b) => a.stageNumber - b.stageNumber) }; const savedSystemTags = uniqueTrimmedStrings((Array.isArray(saved.system_tags) ? saved.system_tags : []) .map((tag) => String(tag).toLowerCase())); const savedTag = savedSystemTags[0] || ''; @@ -3592,6 +3601,32 @@ return combined; } + function resolveDefinitionImportStages(parsed, definition) { + if (!Array.isArray(definition.stages) || !definition.stages.length) { + throw new Error('The definition must contain at least one stage.'); + } + const numbers = new Set(); + for (const stage of definition.stages) { + if (!stage || !Number.isInteger(stage.stage) || stage.stage < 1 || numbers.has(stage.stage)) { + throw new Error('Definition stages must have unique positive stage numbers.'); + } + numbers.add(stage.stage); + } + const clips = new Map(parsed.stages.map((stage) => [stage.stageNumber, stage])); + for (const stage of definition.stages) { + const playback = stage.use_stage ?? stage.stage; + if (!Number.isInteger(playback) || !numbers.has(playback)) { + throw new Error(`Stage p${stage.stage} references missing playback stage p${playback}.`); + } + // NoN references the target's clips directly, not its own use_stage setting. + const source = clips.get(playback); + if (!source) throw new Error(`Stage p${stage.stage} requires animation clips for playback stage p${playback}.`); + } + // Reused stages remain definition metadata, not duplicate editable animations. + return definition.stages.filter((stage) => stage.use_stage == null || stage.use_stage === stage.stage) + .map((stage) => clips.get(stage.stage)).sort((a, b) => a.stageNumber - b.stageNumber); + } + function buildConjoinedImportFile(parsed, outputAnimationId, actorMapping) { const animations = {}; const stageNames = []; @@ -4239,6 +4274,7 @@ parsed.stages = parsed.stages.map((stage) => ({ ...stage, entries: stage.entries.filter((entry) => selectedLabels.has(entry.actorLabel)) })).filter((stage) => stage.entries.length); + parsed.stages = resolveDefinitionImportStages(parsed, definition); // Preserve role references while relabeling the definition for the selected asset clips. const relabeled = clone(definition); relabeled.actors.forEach((actor, index) => { actor.label ||= keys[index]; }); diff --git a/test/animdef-generator.test.js b/test/animdef-generator.test.js index e10f549..fe5a8c3 100644 --- a/test/animdef-generator.test.js +++ b/test/animdef-generator.test.js @@ -43,7 +43,7 @@ function createHarness() { context.adTest.showAnimationDefinitionReviewDialog(group, analysis); return dialogs.at(-1); } - return { open, exports, errors, project }; + return { open, exports, errors, project, analysis }; } function defaults(form) { @@ -52,6 +52,23 @@ function defaults(form) { .map(([key, field]) => [key, field.value])); } +test('metadata-only playback stages remain editable and survive definition export', () => { + const h = createHarness(); + h.analysis.stages = h.analysis.stages.slice(0, 1); + h.project.animationdirector_animdef_definitions = JSON.stringify({ test_animation: { + actors: [{ label: 'player', entity_types: ['minecraft:player'] }, { label: 'wolf', entity_types: ['minecraft:wolf'] }], + stages: [{ stage: 1, loop: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }], + } }); + const dialog = h.open(); + assert.equal(dialog.form.stub_stage_2_use_stage.value, '1'); + dialog.onConfirm(defaults(dialog.form)); + assert.deepEqual(h.errors, []); + assert.equal(h.exports[0].stages.length, 2); + assert.equal(h.exports[0].stages[1].use_stage, 1); + assert.equal(h.exports[0].stages[1].speed, 0.8); + assert.equal(h.analysis.stages.length, 1); +}); + test('generates extended animdef fields and restores them on reopen', () => { const harness = createHarness(); const dialog = harness.open(); diff --git a/test/automatic-import.test.js b/test/automatic-import.test.js index 51d0af8..b64ef3f 100644 --- a/test/automatic-import.test.js +++ b/test/automatic-import.test.js @@ -12,7 +12,7 @@ function harness(capture = false) { const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', ` globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry, resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles, - validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport }; + validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport, resolveDefinitionImportStages }; Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {', ` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`); const storage = new Map(), dialogs = [], errors = [], undo = [], messages = []; @@ -309,6 +309,39 @@ test('a definition actor without a distinct clip still requires a valid mapping' assert.equal(h.context.Project.uuid, 'original'); }); +test('automatic import retains use_stage metadata without duplicating editor animations', async (t) => { + const f = fixtures(t), h = harness(true), source = f.makePack('source'); + const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { + actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], + stages: [{ stage: 1, escapable: true }, { stage: 2, use_stage: 1, speed: 0.8, non_peak: true, escapable: false }], + }); + f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: { + p1_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } }, + sound_effects: { '0': { effect: 'example' } } }, + } }); + f.model(source, 'test:source/entity/wolf.m'); + h.context.selectedFile = { path: file }; + await h.automaticallyImportAnimation(); + assert.deepEqual(h.errors, []); + const plan = h.context.importedPlan; + assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']); + const clips = plan.imported.fileObj.animations; + assert.equal(clips['example.p2'], undefined); + assert.ok(clips['example.p1'].bones.actor1_body); + assert.equal(plan.definition.stages[1].use_stage, 1); + assert.equal(plan.definition.stages[1].speed, 0.8); + assert.equal(plan.definition.stages[1].non_peak, true); + assert.equal(plan.definition.stages[1].escapable, false); +}); + +test('playback references require declared stages and physical clips, not recursive aliases', () => { + const h = harness(); + const parsed = { stages: [{ stageNumber: 1, entries: [{ actorLabel: 'wolf', key: 'p1_wolf', animObj: { bones: {} } }] }] }; + assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 9 }] }), /missing playback stage p9/); + assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1 }, { stage: 1 }] }), /unique positive/); + assert.throws(() => h.resolveDefinitionImportStages(parsed, { stages: [{ stage: 1, use_stage: 2 }, { stage: 2, use_stage: 1 }] }), /requires animation clips.*p2/); +}); + test('canceling actor review leaves the open project and saved folders unchanged', async (t) => { const f = fixtures(t), h = harness(true), source = f.makePack('source'); const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {