From d04f26465ff063ee0c56aa934c8ec87c7c432f5d Mon Sep 17 00:00:00 2001 From: L1Z0 <2grif484d@mozmail.com> Date: Tue, 29 Sep 2026 00:01:48 +0200 Subject: [PATCH] UV fixes, supporting differently sized textures --- AnimationDirector.js | 179 ++++++++++++++++++++++++++++++++-- test/uv-normalization.test.js | 135 +++++++++++++++++++++++++ 2 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 test/uv-normalization.test.js diff --git a/AnimationDirector.js b/AnimationDirector.js index 15cccf6..15eeed7 100644 --- a/AnimationDirector.js +++ b/AnimationDirector.js @@ -2,6 +2,7 @@ const PLUGIN_ID = 'AnimationDirector'; const ACTOR_NAMES_PROPERTY = 'multiactor_actor_names'; const ACTOR_TEXTURES_PROPERTY = 'multiactor_actor_textures'; + const ACTOR_UV_BASIS_PROPERTY = 'animationdirector_actor_uv_basis'; const BONE_TEXTURE_OVERRIDES_PROPERTY = 'multiactor_bone_texture_overrides'; const ACTOR_ANIMDEF_METADATA_PROPERTY = 'multiactor_actor_animdef_metadata'; const ANIMDEF_ANIMATORS_PROPERTY = 'multiactor_animdef_animators'; @@ -36,7 +37,7 @@ }; let importAction, replaceActorAction, importConjoinedAnimationAction, exportConjoinedAction, createAnimationDefinitionAction, actorPreferencesAction, boneTextureOverridesAction, exportGeckoModelAction; - let actorNamesProjectProperty, actorTexturesProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty; + let actorNamesProjectProperty, actorTexturesProjectProperty, actorUvBasisProjectProperty, boneTextureOverridesProjectProperty, actorAnimdefMetadataProjectProperty, animdefAnimatorsProjectProperty, animdefDefinitionsProjectProperty; let actorTexturePreviewProjectListener; const ACTOR_TEXTURE_PREVIEW_EVENTS = ['load_project', 'select_project', 'setup_project', 'load_from_recent_project_data', 'add_texture', 'change_texture_path']; let actorTexturePreviewTimeouts = []; @@ -128,6 +129,18 @@ setProjectStringMap(ACTOR_TEXTURES_PROPERTY, textures); } + function getActorUvBasis() { + try { return JSON.parse(Project?.[ACTOR_UV_BASIS_PROPERTY] || '{}'); } catch (_) { return {}; } + } + + function setActorUvBasis(actorIndex, width, height) { + const all = getActorUvBasis(); + if (all[actorIndex]?.width === width && all[actorIndex]?.height === height) return; + all[actorIndex] = { width, height }; + Project[ACTOR_UV_BASIS_PROPERTY] = JSON.stringify(all); + Project.saved = false; + } + function uniqueTrimmedStrings(values) { const out = []; const seen = new Set(); @@ -560,6 +573,53 @@ cube.updateUV?.(); } + function validUvDimension(value, fallback = 64) { + return Number.isFinite(value) && value > 0 && value <= 8192 ? value : fallback; + } + + function scaleCubeUvs(cube, x, y) { + if (x === 1 && y === 1) return; + if (cube.box_uv) { + if (typeof cube.setUVMode !== 'function') throw new Error('Box UV conversion is unavailable in this Blockbench version.'); + // Blockbench derives face UVs from uv_offset only while box UV mode is active. + cube.preview_controller?.updateUV?.(cube); + cube.setUVMode(false); + Project.box_uv = false; + } + for (const face of Object.values(cube.faces || {})) { + if (!Array.isArray(face.uv) || face.uv.length !== 4) continue; + face.uv = [face.uv[0] * x, face.uv[1] * y, face.uv[2] * x, face.uv[3] * y]; + } + cube.autouv = 0; + cube.preview_controller?.updateUV?.(cube); + } + + function expandProjectUvs(width, height) { + const oldWidth = validUvDimension(Project.texture_width), oldHeight = validUvDimension(Project.texture_height); + const nextWidth = Math.max(oldWidth, width), nextHeight = Math.max(oldHeight, height); + if (nextWidth === oldWidth && nextHeight === oldHeight) return false; + for (const cube of Cube.all) scaleCubeUvs(cube, nextWidth / oldWidth, nextHeight / oldHeight); + Project.texture_width = nextWidth; + Project.texture_height = nextHeight; + Canvas.updateAllUVs?.(); + Project.saved = false; + return true; + } + + function normalizeActorUvs(actorIndex, width, height, original = null) { + width = validUvDimension(width); height = validUvDimension(height); + const basis = original || getActorUvBasis()[actorIndex] || { + width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) }; + const oldWidth = validUvDimension(basis.width), oldHeight = validUvDimension(basis.height); + expandProjectUvs(width, height); + const x = oldWidth / width, y = oldHeight / height; + if (x !== 1 || y !== 1) { + for (const cube of Cube.all) if (cubeBelongsToActor(cube, actorIndex)) scaleCubeUvs(cube, x, y); + } + setActorUvBasis(actorIndex, width, height); + return x !== 1 || y !== 1; + } + // ---------------------------- // Block requirement authoring // ---------------------------- @@ -1122,9 +1182,13 @@ const prefix = `actor${actorIndex}_`; const previousBindings = replaceExisting ? captureActorAnimationBindings(actorIndex) : null; - if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: [], textures: [], + if (manageUndo) Undo.initEdit({ outliner: true, selection: true, elements: Cube.all.slice(), textures: [], + uv_mode: true, actor_uv_edit: true, animations: previousBindings ? previousBindings.map((entry) => entry.animation) : [] }); if (replaceExisting) removeActorGeometry(actorIndex); + const uvWidth = validUvDimension(geo?.description?.texture_width); + const uvHeight = validUvDimension(geo?.description?.texture_height); + expandProjectUvs(uvWidth, uvHeight); const boneByName = new Map(); for (const b of bones) if (b?.name) boneByName.set(b.name, b); @@ -1198,6 +1262,7 @@ if (Array.isArray(c?.uv)) { cube.box_uv = true; cube.uv_offset = [num(c.uv[0], 0), num(c.uv[1], 0)]; + cube.preview_controller?.updateUV?.(cube); } else if (c?.uv && typeof c.uv === 'object') { cube.box_uv = false; applyPerFaceUVWithXFlip(cube, c.uv); @@ -1205,6 +1270,9 @@ } } + normalizeActorUvs(actorIndex, uvWidth, uvHeight, { + width: validUvDimension(Project.texture_width), height: validUvDimension(Project.texture_height) }); + const animationResult = replaceExisting ? rebindActorAnimations(actorIndex, previousBindings) : { rebound: 0, missing: 0 }; @@ -1230,6 +1298,82 @@ } } + function pngDimensions(bytes) { + if (!bytes || bytes.length < 24 || bytes.toString('hex', 0, 8) !== '89504e470d0a1a0a') return null; + const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20); + 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; } + const candidateIds = []; + const addId = (id) => { if (importResourceId(id) && !candidateIds.includes(id)) candidateIds.push(id); }; + if (typeof json.afw_texture === 'string') addId(json.afw_texture); + const path = String(modelPath || '').replace(/\\/g, '/'); + const namespace = /\/assets\/([^/]+)\//.exec(path)?.[1] || 'minecraft'; + const identifier = String(geo?.description?.identifier || '').split('.').pop(); + const filename = path.split('/').pop()?.replace(/(?:\.(?:m|f|mf|m\+f))?\.geo\.json$/i, '') || ''; + for (const entity of [...new Set([identifier, filename])]) { + if (!/^[a-z0-9_]+$/.test(entity)) continue; + addId(`${namespace}:textures/entity/${entity}/${entity}.png`); + addId(`${namespace}:textures/entity/${entity}.png`); + if (namespace !== 'minecraft') { + addId(`minecraft:textures/entity/${entity}/${entity}.png`); + addId(`minecraft:textures/entity/${entity}.png`); + } + } + if (!candidateIds.length) return; + const sourcePaths = []; + try { sourcePaths.push(findImportPack(modelPath, io).root); } catch (_) { /* Standalone model. */ } + try { sourcePaths.push(...savedPackFolders()); } catch (_) { /* No registered packs. */ } + try { sourcePaths.push(...discoverPropSources(io).map((source) => source.file)); } catch (_) { /* No cached assets. */ } + const sources = []; + for (const sourcePath of [...new Set(sourcePaths)]) { + try { sources.push(openPropSource(sourcePath, io)); } catch (_) { /* Try other asset sources. */ } + } + for (const id of candidateIds) { + for (const source of sources) { + const parsed = importResourceId(id); + const relative = `assets/${parsed.namespace}/${parsed.resource}`; + 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 { + 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 }; + } + } 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 }; + Undo.initEdit(edit); + try { + const name = `actor${actorIndex}_${parsed.namespace}_${parsed.resource.split('/').pop()}`; + const texture = new Texture({ name, uv_width: dimensions.width, uv_height: dimensions.height }); + texture.keep_size = true; + const projectId = Project.uuid; + texture.load_callback = () => { if (Project?.uuid === projectId) scheduleActorTexturePreviewApply(); }; + texture.fromDataURL(`data:image/png;base64,${bytes.toString('base64')}`); + texture.name = name; + texture.relative_path = relative; + texture.add(false); + const selected = getProjectActorTextures(); + selected[actorIndex] = name; + setProjectActorTextures(selected); + normalizeActorUvs(actorIndex, dimensions.width, dimensions.height); + applyActorTexturePreview(actorIndex, name); + Undo.finishEdit(`Load Actor ${actorIndex} Texture`, { ...edit, elements: Cube.all.slice(), textures: Texture.all.slice() }); + return; + } catch (error) { Undo.cancelEdit(); throw error; } + } + } + } + function importActorGeckoJson() { if (!Project) { Blockbench.showQuickMessage('No project open. Create/open a project first.', 2500); @@ -1257,7 +1401,9 @@ return; } - importGeometryIntoCurrentProject(geo, getNextActorIndex()); + const actorIndex = getNextActorIndex(); + importGeometryIntoCurrentProject(geo, actorIndex); + tryLoadImportedActorTexture(json, geo, files[0]?.path, actorIndex); } catch (e) { console.error(e); Blockbench.showQuickMessage(`Import failed: ${e?.message || e}`, 4000); @@ -1323,6 +1469,7 @@ return; } importGeometryIntoCurrentProject(geo, actorIndex, true); + tryLoadImportedActorTexture(json, geo, files[0]?.path, actorIndex); } catch (e) { console.error(e); Blockbench.showQuickMessage(`Replacement failed: ${e?.message || e}`, 4000); @@ -1452,6 +1599,7 @@ showAnimdefMetadata = !!result?.show_animdef_metadata; }, onConfirm: (result) => { + let editing = false; try { const outNames = {}; const outTextures = {}; @@ -1483,13 +1631,18 @@ }; } } + const aspects = { elements: Cube.all.slice(), uv_mode: true, actor_preferences_edit: true }; + Undo.initEdit(aspects); + editing = true; setProjectActorNames(outNames); setProjectActorTextures(outTextures); setProjectActorAnimdefMetadata(outAnimdefMetadata); setProjectBoneTextureOverrides(outBoneTextureOverrides); applyAllActorTexturePreviews(true); + Undo.finishEdit('Edit Actor Preferences', { ...aspects, elements: Cube.all.slice() }); return true; } catch (e) { + if (editing) Undo.cancelEdit(); Blockbench.showMessageBox({ title: 'Actor Preferences', message: e?.message || String(e), @@ -1701,6 +1854,10 @@ const texture = findTextureByActorTextureName(textureName); if (!texture) return { applied: 0, missing: true }; + const imageWidth = texture.img?.naturalWidth || texture.canvas?.width || 0; + const imageHeight = texture.display_height || texture.img?.naturalHeight || texture.canvas?.height || 0; + if (imageWidth > 0 && imageHeight > 0) normalizeActorUvs(actorIndex, imageWidth, imageHeight); + let applied = 0; const cubes = Array.isArray(Cube.all) ? Cube.all : []; for (const cube of cubes) { @@ -3833,7 +3990,8 @@ // Automatic import reads only explicitly registered, extracted packs. No network access. const PACK_FOLDERS_KEY = 'animationdirector_pack_folders'; const IMPORT_METADATA = [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, BONE_TEXTURE_OVERRIDES_PROPERTY, - ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY, PROP_PREVIEWS_PROPERTY]; + ACTOR_ANIMDEF_METADATA_PROPERTY, ANIMDEF_ANIMATORS_PROPERTY, ANIMDEF_DEFINITIONS_PROPERTY, BLOCK_REQUIREMENTS_PROPERTY, + PROP_PREVIEWS_PROPERTY, ACTOR_UV_BASIS_PROPERTY]; // Item previews are private Three.js attachments, never actor geometry or animation tracks. function propResourcePath(id, kind, extension) { @@ -4885,7 +5043,7 @@ cancelBlockRequirementUpdate(); if (!newProject(Formats.geckolib_model)) throw new Error('Could not create the GeckoLib project.'); const projectId = Project.uuid; - const aspects = { outliner: true, selection: true, elements: [], groups: [], textures: [], animations: [], + const aspects = { outliner: true, selection: true, elements: [], groups: [], textures: [], animations: [], uv_mode: true, automatic_animation_import: true }; Undo.initEdit(aspects); try { @@ -5096,6 +5254,9 @@ You can import or export complete AFW animation IDs as one file containing all s default: '{}', }); } + if (!ModelProject.properties[ACTOR_UV_BASIS_PROPERTY]) { + actorUvBasisProjectProperty = new Property(ModelProject, 'string', ACTOR_UV_BASIS_PROPERTY, { default: '{}' }); + } if (!ModelProject.properties[BONE_TEXTURE_OVERRIDES_PROPERTY]) { boneTextureOverridesProjectProperty = new Property(ModelProject, 'string', BONE_TEXTURE_OVERRIDES_PROPERTY, { default: '{}', @@ -5136,6 +5297,12 @@ You can import or export complete AFW animation IDs as one file containing all s if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}'; if (aspects.animdef_edit) save[ANIMDEF_DEFINITIONS_PROPERTY] = Project[ANIMDEF_DEFINITIONS_PROPERTY] || '{}'; if (aspects.prop_preview_edit) save[PROP_PREVIEWS_PROPERTY] = Project[PROP_PREVIEWS_PROPERTY] || '{}'; + if (aspects.actor_uv_edit) save[ACTOR_UV_BASIS_PROPERTY] = Project[ACTOR_UV_BASIS_PROPERTY] || '{}'; + if (aspects.actor_texture_edit) save[ACTOR_TEXTURES_PROPERTY] = Project[ACTOR_TEXTURES_PROPERTY] || '{}'; + if (aspects.actor_preferences_edit) { + for (const property of [ACTOR_NAMES_PROPERTY, ACTOR_TEXTURES_PROPERTY, ACTOR_ANIMDEF_METADATA_PROPERTY, + BONE_TEXTURE_OVERRIDES_PROPERTY, ACTOR_UV_BASIS_PROPERTY]) save[property] = Project[property] || '{}'; + } if (aspects.automatic_animation_import) { for (const property of IMPORT_METADATA) save[property] = Project[property] ?? (property === ANIMDEF_ANIMATORS_PROPERTY ? '' : '{}'); } @@ -5279,7 +5446,7 @@ You can import or export complete AFW animation IDs as one file containing all s }, onunload() { clearPropMeshes(); propSources.clear(); - propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); + propAction?.delete(); propDialog?.delete(); propProjectProperty?.delete(); actorUvBasisProjectProperty?.delete(); if (propListener) for (const event of ['select_animation','select_project','load_project','unselect_project','undo','redo','render_frame']) Blockbench.removeListener(event, propListener); propDialog = undefined; propListener = undefined; clearScheduledActorTexturePreviewApplies(); diff --git a/test/uv-normalization.test.js b/test/uv-normalization.test.js new file mode 100644 index 0000000..5c5e9ac --- /dev/null +++ b/test/uv-normalization.test.js @@ -0,0 +1,135 @@ +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'); + +function harness() { + const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace( + ' Plugin.register(PLUGIN_ID, {', + ' globalThis.uvTest = { normalizeActorUvs, expandProjectUvs, scaleCubeUvs, getActorUvBasis, tryLoadImportedActorTexture, applyActorTexturePreview, importGeometryIntoCurrentProject };\n Plugin.register(PLUGIN_ID, {' + ); + class Group { + static all = []; + constructor(data) { Object.assign(this, data); } + init() { Group.all.push(this); return this; } + addTo(parent) { this.parent = parent; return this; } + } + const cubes = []; + class Cube { + static all = cubes; + constructor(name, box = false) { + Object.assign(this, typeof name === 'object' ? name : { name }); + this.box_uv = box; + this.faces = { north: { uv: [0, 0, 16, 16] } }; + this.preview_controller = { updateUV(cube) { + if (!cube.box_uv || !cube.uv_offset || !cube.from || !cube.to) return; + const sizeX = cube.to[0] - cube.from[0], sizeY = cube.to[1] - cube.from[1]; + const sizeZ = cube.to[2] - cube.from[2]; + const [u, v] = cube.uv_offset; + cube.faces.north.uv = [u + sizeZ, v + sizeZ, u + sizeZ + sizeX, v + sizeZ + sizeY]; + } }; + cubes.push(this); + } + init() { return this; } + addTo(parent) { this.parent = parent; return this; } + setUVMode(box) { this.box_uv = box; } + } + class Texture { + static all = []; + constructor(data) { Object.assign(this, data); this.uuid = data.name; } + fromDataURL(url) { this.source = url; return this; } + add() { Texture.all.push(this); return this; } + } + const context = { Buffer, requireNativeModule: require, Group, Cube, Texture, console, + setTimeout(callback) { callback(); }, + Undo: { initEdit() {}, finishEdit() {}, cancelEdit() {} }, + Project: { texture_width: 64, texture_height: 64, box_uv: true }, + Canvas: { updateAllUVs() {} }, Plugin: { register() {} }, + localStorage: { getItem() { return null; } }, + }; + vm.runInNewContext(source, context); + return { ...context.uvTest, context, Cube, Texture }; +} + +test('growing the project UV canvas preserves other actors and converts box UVs', () => { + const h = harness(), actor1 = new h.Cube('actor1_body', false), actor2 = new h.Cube('actor2_body', true); + h.normalizeActorUvs(1, 64, 64); + h.normalizeActorUvs(2, 64, 64); + h.normalizeActorUvs(1, 128, 64); + assert.equal(h.context.Project.texture_width, 128); + assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 16, 16]); + assert.deepEqual(Array.from(actor2.faces.north.uv), [0, 0, 32, 16]); + assert.equal(actor2.box_uv, false); + h.normalizeActorUvs(1, 32, 64); + assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 64, 16]); + assert.deepEqual(Array.from(actor2.faces.north.uv), [0, 0, 32, 16]); + h.normalizeActorUvs(1, 32, 64); + assert.deepEqual(Array.from(actor1.faces.north.uv), [0, 0, 64, 16], 'Repeated refreshes must not rescale UVs'); +}); + +test('assigning a manually imported texture to an actor normalizes only that actor once', () => { + const h = harness(), selected = new h.Cube('actor1_body'), other = new h.Cube('actor2_body'); + h.normalizeActorUvs(1, 64, 64); h.normalizeActorUvs(2, 64, 64); + const texture = new h.Texture({ name: 'custom.png' }); + texture.img = { naturalWidth: 32, naturalHeight: 64 }; + texture.add(); + h.applyActorTexturePreview(1, 'custom.png'); + assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]); + assert.deepEqual(Array.from(other.faces.north.uv), [0, 0, 16, 16]); + h.applyActorTexturePreview(1, 'custom.png'); + assert.deepEqual(Array.from(selected.faces.north.uv), [0, 0, 32, 16]); +}); + +test('importing a larger actor preserves UV mapping of the earlier model', () => { + const h = harness(); + const geometry = (width) => ({ description: { texture_width: width, texture_height: width }, bones: [{ name: 'body', cubes: [ + { origin: [0, 0, 0], size: [2, 2, 2], uv: { north: { uv: [0, 0], uv_size: [16, 16] } } }, + ] }] }); + h.importGeometryIntoCurrentProject(geometry(64), 1, false, false); + h.importGeometryIntoCurrentProject(geometry(128), 2, false, false); + assert.equal(h.context.Project.texture_width, 128); + assert.deepEqual(Array.from(h.Cube.all[0].faces.north.uv), [0, 0, 32, 32]); + assert.deepEqual(Array.from(h.Cube.all[1].faces.north.uv), [0, 0, 16, 16]); + assert.equal(h.getActorUvBasis()['1'].width, 64); + assert.equal(h.getActorUvBasis()['2'].width, 128); +}); + +test('box UV import resolves face coordinates before converting to a smaller texture', () => { + const h = harness(); + h.importGeometryIntoCurrentProject({ description: { texture_width: 32, texture_height: 32 }, bones: [ + { name: 'body', cubes: [{ origin: [0, 0, 0], size: [2, 3, 4], uv: [8, 4] }] }, + ] }, 1, false, false); + const cube = h.Cube.all[0]; + assert.equal(cube.box_uv, false); + assert.deepEqual(Array.from(cube.faces.north.uv), [24, 16, 28, 22]); +}); + +test('box UV import keeps its original coordinates when no scaling is needed', () => { + const h = harness(); + h.importGeometryIntoCurrentProject({ description: { texture_width: 64, texture_height: 64 }, bones: [ + { name: 'body', cubes: [{ origin: [0, 0, 0], size: [2, 3, 4], uv: [8, 4] }] }, + ] }, 1, false, false); + const cube = h.Cube.all[0]; + assert.equal(cube.box_uv, true); + assert.deepEqual(Array.from(cube.faces.north.uv), [12, 8, 14, 11]); +}); + +test('manual model import can load an explicitly referenced texture from its extracted pack', (t) => { + const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-uv-pack-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const write = (relative, contents) => { const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, contents); return file; }; + write('pack.mcmeta', '{}'); + const model = write('assets/example/geckolib/models/entity/demo.geo.json', '{}'); + const png = Buffer.alloc(24); Buffer.from('89504e470d0a1a0a', 'hex').copy(png); png.writeUInt32BE(32, 16); png.writeUInt32BE(64, 20); + write('assets/example/textures/entity/demo.png', png); + new h.Cube('actor1_body'); + h.normalizeActorUvs(1, 64, 64); + h.tryLoadImportedActorTexture({ afw_texture: 'example:textures/entity/demo.png' }, {}, model, 1); + assert.equal(h.Texture.all.length, 1); + assert.equal(h.Texture.all[0].relative_path, 'assets/example/textures/entity/demo.png'); + assert.equal(JSON.parse(h.context.Project.multiactor_actor_textures)['1'], h.Texture.all[0].name); + assert.equal(h.getActorUvBasis()['1'].width, 32); +});