diff --git a/AnimationDirector.js b/AnimationDirector.js index 15eeed7..2cfe94c 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -1304,9 +1304,7 @@ return width > 0 && height > 0 && width <= 8192 && height <= 8192 ? { width, height } : null; } - function tryLoadImportedActorTexture(json, geo, modelPath, actorIndex) { - let io; - try { io = desktopFiles(); } catch (_) { return; } + function actorTextureCandidates(json, geo, modelPath) { const candidateIds = []; const addId = (id) => { if (importResourceId(id) && !candidateIds.includes(id)) candidateIds.push(id); }; if (typeof json.afw_texture === 'string') addId(json.afw_texture); @@ -1323,6 +1321,25 @@ addId(`minecraft:textures/entity/${entity}.png`); } } + return candidateIds; + } + + function importTextureDimensions(bytes, metadata = null) { + let dimensions = pngDimensions(bytes); + if (!dimensions) return null; + const animation = metadata?.animation; + if (animation) { + const width = validUvDimension(animation.width, dimensions.width); + const height = validUvDimension(animation.height, width); + if (width <= dimensions.width && height <= dimensions.height) dimensions = { width, height }; + } + return dimensions; + } + + function tryLoadImportedActorTexture(json, geo, modelPath, actorIndex) { + let io; + try { io = desktopFiles(); } catch (_) { return; } + const candidateIds = actorTextureCandidates(json, geo, modelPath); if (!candidateIds.length) return; const sourcePaths = []; try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ } @@ -1342,12 +1359,7 @@ let dimensions = pngDimensions(bytes); if (!dimensions) continue; try { - const animation = propJson(source, `${relative}.mcmeta`)?.animation; - if (animation) { - const width = validUvDimension(animation.width, dimensions.width); - const height = validUvDimension(animation.height, width); - if (width <= dimensions.width && height <= dimensions.height) dimensions = { width, height }; - } + dimensions = importTextureDimensions(bytes, propJson(source, `${relative}.mcmeta`)); } catch (_) { /* Keep the PNG dimensions when metadata is invalid. */ } const edit = { elements: Cube.all.filter((cube) => cubeBelongsToActor(cube, actorIndex)), textures: Texture.all.slice(), uv_mode: true, actor_uv_edit: true, actor_texture_edit: true }; @@ -5020,22 +5032,53 @@ } function loadImportTextures(row, packs, io) { - const ids = new Set(Object.values(row.json.afw_bone_textures || {}).filter((value) => typeof value === 'string')); - if (typeof row.json.afw_texture === 'string') ids.add(row.json.afw_texture); - else { - const entity = importResourceId(row.entity); - for (const resource of [`${entity.namespace}:textures/entity/${entity.resource}/${entity.resource}.png`, - `${entity.namespace}:textures/entity/${entity.resource}.png`]) { - if (findImportResource(packs, resource, '', '', io)) { row.defaultTexture = resource; ids.add(resource); break; } - } - } - if (typeof row.json.afw_texture === 'string') row.defaultTexture = row.json.afw_texture; + const candidates = actorTextureCandidates(row.json, row.geo, row.resolved.file); + const ids = new Set([...candidates, ...Object.values(row.json.afw_bone_textures || {}) + .filter((value) => typeof value === 'string')]); row.textures = new Map(); + 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 id = resource.includes(':') ? resource : `minecraft:${resource}`; - const texture = findImportResource(packs, id, '', '', io); - if (texture) row.textures.set(resource, texture); + const texture = findImportResource(searchPacks, id, '', '', io); + if (texture) { + let dimensions = null; + try { + if (io.fs.statSync(texture.file).size <= 4 * 1024 * 1024) { + const bytes = io.fs.readFileSync(texture.file); + let metadata = null; + try { metadata = readImportJson(`${texture.file}.mcmeta`, io); } catch (_) { /* Static texture. */ } + dimensions = importTextureDimensions(bytes, metadata); + } + } catch (_) { /* Let Blockbench report unreadable texture files. */ } + row.textures.set(resource, { ...texture, dimensions }); + continue; + } + if (!cachedSources) { + cachedSources = []; + for (const entry of discoverPropSources(io)) { + try { cachedSources.push(openPropSource(entry.file, io)); } catch (_) { /* Try another asset source. */ } + } + } + const parsed = importResourceId(id); + if (!parsed) continue; + const relative = `assets/${parsed.namespace}/${parsed.resource}`; + for (const source of cachedSources) { + let bytes; + try { bytes = source.read(relative); } catch (_) { continue; } + if (!bytes || bytes.length > 4 * 1024 * 1024) continue; + let dimensions = pngDimensions(bytes); + 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; + } } + row.defaultTexture = candidates.find((id) => row.textures.has(id)); } function commitAutomaticImport(plan) { @@ -5059,22 +5102,30 @@ metadata[index] = plan.definition.actors[offset]; importGeometryIntoCurrentProject(row.geo, index, false, false); for (const [name, resource] of row.textures) { - if (!loadedTextures.has(resource.file)) { - const texture = new Texture({ name, uv_width: row.geo.description?.texture_width || 64, - uv_height: row.geo.description?.texture_height || 64 }); + const key = resource.file || `${resource.relative}:${name}`; + if (!loadedTextures.has(key)) { + const texture = new Texture({ name, uv_width: resource.dimensions?.width || row.geo.description?.texture_width || 64, + uv_height: resource.dimensions?.height || row.geo.description?.texture_height || 64 }); texture.keep_size = true; texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); }; - texture.fromPath(resource.file); + if (resource.file) texture.fromPath(resource.file); + else texture.fromDataURL(`data:image/png;base64,${resource.bytes.toString('base64')}`); texture.name = name; + if (resource.relative) texture.relative_path = resource.relative; texture.add(false); - loadedTextures.set(resource.file, name); + loadedTextures.set(key, name); } } - if (row.textures.has(row.defaultTexture)) textures[index] = loadedTextures.get(row.textures.get(row.defaultTexture).file); + const base = row.textures.get(row.defaultTexture); + if (base) { + textures[index] = loadedTextures.get(base.file || `${base.relative}:${row.defaultTexture}`); + if (base.dimensions) normalizeActorUvs(index, base.dimensions.width, base.dimensions.height); + } for (const [bone, textureName] of Object.entries(row.json.afw_bone_textures || {})) { const group = Group.all.find((candidate) => candidate.name === `actor${index}_${bone}`); - if (group && row.textures.has(textureName)) overrides[group.uuid] = { uuid: group.uuid, name: group.name, - texture: loadedTextures.get(row.textures.get(textureName).file) }; + const boneTexture = row.textures.get(textureName); + if (group && boneTexture) overrides[group.uuid] = { uuid: group.uuid, name: group.name, + texture: loadedTextures.get(boneTexture.file || `${boneTexture.relative}:${textureName}`) }; } } setProjectActorNames(names); diff --git a/test/automatic-import.test.js b/test/automatic-import.test.js index fb7784e..67cae9f 100644 --- a/test/automatic-import.test.js +++ b/test/automatic-import.test.js @@ -27,6 +27,7 @@ function harness(capture = false) { static all = []; constructor(data) { Object.assign(this, data); } fromPath(file) { this.path = file; this.name = path.basename(file); return this; } + fromDataURL(url) { this.source = url; return this; } add(undo) { assert.equal(undo, false); Texture.all.push(this); } } const context = { @@ -272,6 +273,39 @@ test('complete import prepares all stages, actor models, local textures and defi assert.ok(h.storage.get('animationdirector_pack_folders').includes(source.root.replace(/\\/g, '\\\\'))); }); +test('complete import finds model texture in cached assets and uses its PNG dimensions', async (t) => { + const f = fixtures(t), h = harness(true), source = f.makePack('source'); + const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), { + actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }], + }); + f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { + animations: { p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } } }, + }); + f.model(source, 'test:source/entity/wolf.m'); + const cache = path.join(f.root, 'cached-assets'); + const image = path.join(cache, 'assets/test/textures/entity/wolf.png'); + fs.mkdirSync(path.dirname(image), { recursive: true }); + const png = Buffer.alloc(24); + Buffer.from('89504e470d0a1a0a', 'hex').copy(png); + png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20); + fs.writeFileSync(image, png); + h.storage.set('animationdirector_prop_source', cache); + h.context.selectedFile = { path: defFile }; + await h.automaticallyImportAnimation(); + assert.deepEqual(h.errors, []); + const row = h.context.importedPlan.rows[0]; + assert.equal(row.defaultTexture, 'test:textures/entity/wolf.png'); + assert.equal(row.textures.get(row.defaultTexture).dimensions.width, 32); + assert.equal(row.textures.get(row.defaultTexture).dimensions.height, 64); + + const importing = harness(); + importing.commitAutomaticImport(h.context.importedPlan); + assert.equal(importing.context.Texture.all[0].uv_width, 32); + assert.equal(importing.context.Texture.all[0].uv_height, 64); + assert.equal(JSON.parse(importing.context.Project.animationdirector_actor_uv_basis)['1'].width, 32); + assert.equal(JSON.parse(importing.context.Project.multiactor_actor_textures)['1'], row.defaultTexture); +}); + test('definition actors exclude extra clips, their effects and stages containing only unused actors', 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'), {