diff --git a/nonpacks/library/management/__init__.py b/nonpacks/library/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nonpacks/library/management/commands/__init__.py b/nonpacks/library/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nonpacks/library/management/commands/refresh_vanilla_index.py b/nonpacks/library/management/commands/refresh_vanilla_index.py new file mode 100644 index 0000000..0053e01 --- /dev/null +++ b/nonpacks/library/management/commands/refresh_vanilla_index.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from library.vanilla_textures import refresh_vanilla_index + + +class Command(BaseCommand): + help = 'Rebuild the vanilla entity-texture index (media/vanilla/entity_index.json).' + + def handle(self, *args, **options): + index = refresh_vanilla_index() + self.stdout.write(self.style.SUCCESS(f'Indexed {len(index)} entities.')) diff --git a/nonpacks/library/urls.py b/nonpacks/library/urls.py index 6aaefe9..d3abc59 100644 --- a/nonpacks/library/urls.py +++ b/nonpacks/library/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('packs//versions//download/', views.version_download, name='version_download'), path('packs//versions//files//download/', views.version_file_download, name='version_file_download'), path('packs//versions//asset/', views.pack_asset, name='pack_asset'), + path('vanilla/', views.vanilla_asset, name='vanilla_asset'), path('packs//guide///', views.guide_doc, name='guide_doc'), path('packs//gallery/upload/', views.asset_upload, name='asset_upload'), path('packs//gallery//thumb/', views.asset_thumbnail, name='asset_thumbnail'), diff --git a/nonpacks/library/vanilla_textures.py b/nonpacks/library/vanilla_textures.py new file mode 100644 index 0000000..1fc9a64 --- /dev/null +++ b/nonpacks/library/vanilla_textures.py @@ -0,0 +1,107 @@ +"""Build the vanilla entity-texture index for the model viewer. + +Scans ``MEDIA_ROOT/vanilla/entity/`` (extracted from a Minecraft client jar) +and writes ``MEDIA_ROOT/vanilla/entity_index.json`` mapping each entity name to +the best "base skin" relative path, so the renderer can fall back to real +vanilla textures for models that have no pack textures of their own. +""" +import json +import os + +from django.conf import settings + +ENTITY_ROOT = 'entity' +INDEX_NAME = 'entity_index.json' + +# Entity names whose vanilla layout differs from the model-name convention. +ALIASES = { + 'polar_bear': 'bear/polarbear.png', + 'cave_spider': 'spider/cave_spider.png', + 'husk': 'zombie/husk.png', + 'drowned': 'zombie/drowned.png', + 'stray': 'skeleton/stray.png', + 'zoglin': 'hoglin/zoglin.png', + 'piglin_brute': 'piglin/piglin_brute.png', + 'zombified_piglin': 'piglin/zombified_piglin.png', + 'horse': 'horse/horse_brown.png', + 'donkey': 'horse/donkey.png', + 'mule': 'horse/mule.png', + 'skeleton_horse': 'horse/horse_skeleton.png', + 'zombie_horse': 'horse/horse_zombie.png', + 'evoker': 'illager/evoker.png', + 'pillager': 'illager/pillager.png', + 'vex': 'illager/vex.png', + 'vindicator': 'illager/vindicator.png', + 'ravager': 'illager/ravager.png', + 'cat': 'cat/tabby.png', + 'cow': 'cow/temperate_cow.png', + 'pig': 'pig/temperate_pig.png', + 'slime_size_0': 'slime/slime.png', + 'slime_size_1': 'slime/slime.png', + 'slime_size_3': 'slime/slime.png', + 'player': 'player/wide/steve.png', + 'player_slim': 'player/slim/steve.png', +} + +# Texture names that are auxiliary layers, never a base skin. +_LAYER_KEYWORDS = ( + 'overlay', '_eyes', '_armor', '_collar', '_markings', '_tame', '_angry', + '_saddle', '_crack', '_outer', '_inner', '_layer', '_emissive', '_pelt', + '_fur', '_shell', '_crackiness', +) + + +def _base_candidates(entity): + """Return (entity, [candidate relative paths]) in priority order.""" + if entity in ALIASES: + return entity, [ALIASES[entity]] + root = os.path.join(settings.MEDIA_ROOT, 'vanilla', ENTITY_ROOT) + top = os.path.join(root, entity + '.png') + if os.path.isfile(top): + return entity, [entity + '.png'] + folder = os.path.join(root, entity) + if not os.path.isdir(folder): + return entity, [] + try: + names = sorted(os.listdir(folder)) + except OSError: + return entity, [] + pngs = [n for n in names if n.endswith('.png')] + exact = [f'{entity}/{n}' for n in pngs if n == f'{entity}.png'] + default = [f'{entity}/{n}' for n in pngs if n in ('temperate_' + entity + '.png', 'default.png')] + plain = [ + f'{entity}/{n}' for n in pngs + if not any(k in n for k in _LAYER_KEYWORDS) + ] + return entity, exact + default + plain + [f'{entity}/{n}' for n in pngs] + + +def refresh_vanilla_index(): + """Rebuild the entity → base-skin map from the extracted vanilla set.""" + root = os.path.join(settings.MEDIA_ROOT, 'vanilla', ENTITY_ROOT) + if not os.path.isdir(root): + raise FileNotFoundError(f'{root} missing — extract vanilla entity textures first') + + index = {} + for entry in sorted(os.listdir(root)): + rel = os.path.join(root, entry) + if entry.endswith('.png'): + entity = entry[:-4] + if not index.get(entity): + index[entity] = entry + continue + if not os.path.isdir(rel): + continue + entity = entry + _, candidates = _base_candidates(entity) + if candidates and not index.get(entity): + index[entity] = candidates[0] + + for entity, rel in ALIASES.items(): + if not index.get(entity): + index[entity] = rel + + out = os.path.join(settings.MEDIA_ROOT, 'vanilla', INDEX_NAME) + with open(out, 'w') as f: + json.dump(index, f, indent=1, sort_keys=True) + return index diff --git a/nonpacks/library/views.py b/nonpacks/library/views.py index 5c6e9bc..5c17adf 100644 --- a/nonpacks/library/views.py +++ b/nonpacks/library/views.py @@ -423,6 +423,19 @@ def api_packs_latest(request, namespace, pack_id): }) +def vanilla_asset(request, asset_path): + """Serve a bundled Minecraft vanilla resource (e.g. entity textures) — gated.""" + if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'): + raise Http404 + full = Path(settings.MEDIA_ROOT) / 'vanilla' / asset_path + if not full.is_file(): + raise Http404 + content_type = mimetypes.guess_type(asset_path)[0] or 'application/octet-stream' + response = HttpResponse(full.read_bytes(), content_type=content_type) + response['Cache-Control'] = 'public, max-age=86400' + return response + + def guide_doc(request, slug, version_id, file_uuid): """Render one markdown guide document server-side (used by the switcher).""" version = get_object_or_404( diff --git a/nonpacks/library/zips.py b/nonpacks/library/zips.py index 03e3d23..67cf394 100644 --- a/nonpacks/library/zips.py +++ b/nonpacks/library/zips.py @@ -275,10 +275,29 @@ def read_models_manifest(path): for bone in geom.get('bones') or []: bone_count += 1 cube_count += len(bone.get('cubes') or []) + + entity = name.rsplit('/', 1)[-1] + if entity.endswith('.geo.json'): + entity = entity[:-len('.geo.json')] + for suffix in ('.mf', '.fm', '.m', '.f', '.g'): + if entity.endswith(suffix): + entity = entity[:-len(suffix)] + break + + # A pack-provided full skin is the model's default texture when the + # model has no per-bone textures of its own. + default_texture = '' + if not bone_textures and entity: + for n in names: + if n.endswith(f'/textures/entity/{entity}/{entity}.png') or n.endswith(f'/textures/entity/{entity}.png'): + default_texture = n + break models.append({ 'name': name.rsplit('/', 1)[-1], 'member': name, 'identifier': identifier, + 'entity': entity, + 'default_texture': default_texture, 'bones': bone_count, 'cubes': cube_count, 'bone_textures': bone_textures, diff --git a/nonpacks/static/js/geo_builder.js b/nonpacks/static/js/geo_builder.js index fee66f8..6b83c42 100644 --- a/nonpacks/static/js/geo_builder.js +++ b/nonpacks/static/js/geo_builder.js @@ -83,11 +83,29 @@ }; } + // Blockbench per-face UV: {north:{uv:[u,v], uv_size:[w,h]}, ...} with + // up/down for top/bottom. Negative uv_size mirrors that face (180° flip). + function perFaceRects(uv) { + var map = { top: 'up', bottom: 'down', north: 'north', south: 'south', east: 'east', west: 'west' }; + var rects = {}; + for (var face in map) { + var p = uv[map[face]]; + if (!p || !Array.isArray(p.uv)) { + rects[face] = [0, 0, 0, 0]; + continue; + } + var w = (p.uv_size && p.uv_size[0]) || 0; + var h = (p.uv_size && p.uv_size[1]) || 0; + rects[face] = [p.uv[0], p.uv[1], w, h]; + } + return rects; + } + // Build one cube as a BufferGeometry-compatible mesh: // 24 positions (6 faces x 4 corners), 24 UVs, 36 indices, outward winding. function cubeGeometry(size, uv, tw, th) { var hx = size[0] / 2, hy = size[1] / 2, hz = size[2] / 2; - var rects = boxUVRects(uv, size); + var rects = Array.isArray(uv) ? boxUVRects(uv, size) : perFaceRects(uv); var order = ['east', 'west', 'top', 'bottom', 'south', 'north']; var outward = { east: [1, 0, 0], west: [-1, 0, 0], @@ -167,13 +185,28 @@ } // ---------- model build ---------- + // Resource locations like "needsofnature:textures/entity/x.png" resolve to + // the zip member "assets/needsofnature/textures/entity/x.png". + function resourceToMember(resource) { + resource = String(resource); + if (resource.indexOf(':') !== -1) { + var p = resource.split(':'); + return 'assets/' + p.shift() + '/' + p.join(':'); + } + if (resource.indexOf('assets/') === 0) return resource; + return 'assets/' + resource; + } + // Returns { cubes: [{matrix, size, uv, texture}], texture_width, texture_height } function build(geo) { var geometry = ((geo['minecraft:geometry'] || [])[0]) || null; if (!geometry) return { cubes: [], texture_width: 64, texture_height: 64 }; var tw = (geometry.description && geometry.description.texture_width) || 64; var th = (geometry.description && geometry.description.texture_height) || 64; - var textures = geo['afw_bone_textures'] || {}; + var textures = {}; + for (var k in (geo['afw_bone_textures'] || {})) { + textures[k] = resourceToMember(geo['afw_bone_textures'][k]); + } var defaultTex = null; for (var k in textures) { defaultTex = textures[k]; break; } @@ -247,6 +280,8 @@ build: build, cubeGeometry: cubeGeometry, boxUVRects: boxUVRects, + perFaceRects: perFaceRects, + resourceToMember: resourceToMember, transformPoint: transformPoint, identity: identity, translation: translation, diff --git a/nonpacks/static/js/model_viewer.js b/nonpacks/static/js/model_viewer.js index a4f69d9..81dfdd4 100644 --- a/nonpacks/static/js/model_viewer.js +++ b/nonpacks/static/js/model_viewer.js @@ -1,6 +1,12 @@ // GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder -// geometry math (bone transforms + box-UV) and renders world-space cubes with -// Three.js. Textures are served through the gated pack_asset endpoint. +// geometry math (bone transforms + box/per-face UV) and renders world-space +// cubes with Three.js. Textures are served through the gated pack_asset and +// vanilla_asset endpoints, resolved per cube: +// 1. afw_bone_textures (pack member), composited over the vanilla entity +// skin when one exists (NoN `*_features` overlays sit on the vanilla base) +// 2. the model's default pack skin (server-matched) +// 3. the bundled vanilla entity skin +// 4. gray (function () { let renderer = null; let scene = null; @@ -9,32 +15,117 @@ let rafId = null; const textureLoader = new THREE.TextureLoader(); const materialCache = {}; + let vanillaIndex = null; + let vanillaIndexPromise = null; function assetUrl(baseUrl, member) { return baseUrl.replace('ASSET', member); } + function entityNameFromMember(member) { + let base = member.slice(member.lastIndexOf('/') + 1); + if (base.endsWith('.geo.json')) base = base.slice(0, -'.geo.json'.length); + for (const suffix of ['.mf', '.fm', '.m', '.f', '.g']) { + if (base.endsWith(suffix)) { base = base.slice(0, -suffix.length); break; } + } + return base; + } + function buildGeometry(size, uv, tw, th) { const g = GeoBuilder.cubeGeometry(size, uv, tw, th); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(g.positions, 3)); geometry.setAttribute('uv', new THREE.Float32BufferAttribute(g.uvs, 2)); geometry.setIndex(g.indices); + geometry.computeVertexNormals(); return geometry; } - function materialFor(member, baseUrl) { - if (materialCache[member || '__gray__']) return materialCache[member || '__gray__']; + function fetchVanillaIndex(opts) { + if (vanillaIndexPromise) return vanillaIndexPromise; + if (!opts.vanillaBaseUrl) { + vanillaIndexPromise = Promise.resolve({}); + } else { + vanillaIndexPromise = fetch(assetUrl(opts.vanillaBaseUrl, 'entity_index.json')) + .then(r => r.ok ? r.json() : {}) + .catch(() => ({})); + } + return vanillaIndexPromise; + } + + function vanillaSkinUrl(opts, index, entity) { + const rel = (index && index[entity]) || null; + return rel ? assetUrl(opts.vanillaBaseUrl, 'entity/' + rel) : null; + } + + // desc: { url, vanillaUrl } — url is the pack/overlay member, vanillaUrl the + // base skin to composite it over (when available). + function resolveTexture(cube, entity, opts, index) { + if (cube.texture) { + return { + url: assetUrl(opts.baseUrl, cube.texture), + vanillaUrl: vanillaSkinUrl(opts, index, entity), + }; + } + if (opts.defaultTexture) { + return { url: assetUrl(opts.baseUrl, opts.defaultTexture), vanillaUrl: null }; + } + const rel = (index && index[entity]) || null; + if (rel) { + return { url: null, vanillaUrl: vanillaSkinUrl(opts, index, entity) }; + } + return null; + } + + function loadImage(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('img')); + img.src = url; + }); + } + + async function prepareMaterial(desc, tw, th) { + const key = (desc.url || '') + '|' + (desc.vanillaUrl || ''); + if (materialCache[key]) return materialCache[key]; + + let texture = null; + if (desc.url && desc.vanillaUrl) { + try { + const [overlay, base] = await Promise.all([loadImage(desc.url), loadImage(desc.vanillaUrl)]); + const canvas = document.createElement('canvas'); + canvas.width = tw; + canvas.height = th; + const ctx = canvas.getContext('2d'); + ctx.drawImage(base, 0, 0, tw, th); + ctx.drawImage(overlay, 0, 0, tw, th); + texture = new THREE.CanvasTexture(canvas); + } catch (e) { + texture = null; + } + } + const singleUrl = texture ? null : (desc.url || desc.vanillaUrl); + if (singleUrl) texture = textureLoader.load(singleUrl); + let material; - if (member) { - const texture = textureLoader.load(assetUrl(baseUrl, member)); + if (texture) { texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.9, metalness: 0.0 }); + if (singleUrl) { + textureLoader.load(singleUrl, undefined, undefined, () => { + if (material.map) material.map.dispose(); + material.map = null; + material.color.setHex(0x9a9a9a); + material.needsUpdate = true; + }); + } } else { material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 }); } - materialCache[member || '__gray__'] = material; + materialCache[key] = material; return material; } @@ -49,7 +140,7 @@ for (const key in materialCache) delete materialCache[key]; } - function render(member, container, baseUrl) { + function render(member, container, opts) { if (renderer) dispose(); const width = container.clientWidth || 480; const height = container.clientHeight || 480; @@ -60,7 +151,7 @@ camera.position.set(0, 20, 60); camera.lookAt(0, 12, 0); - renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio || 1); container.appendChild(renderer.domElement); @@ -76,14 +167,29 @@ meshRoot = new THREE.Group(); scene.add(meshRoot); - fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } }) + const entity = entityNameFromMember(member); + fetch(assetUrl(opts.baseUrl, member), { headers: { 'Accept': 'application/json' } }) .then(r => { if (!r.ok) throw new Error('http'); return r.json(); }) - .then(geo => { + .then(geo => Promise.all([geo, fetchVanillaIndex(opts)])) + .then(([geo, index]) => { const built = GeoBuilder.build(geo); if (!built.cubes.length) throw new Error('no cubes'); - for (const cube of built.cubes) { + const tw = built.texture_width, th = built.texture_height; + const jobs = built.cubes.map((cube) => { + const desc = resolveTexture(cube, entity, opts, index); + return desc ? prepareMaterial(desc, tw, th) : Promise.resolve(null); + }); + return Promise.all(jobs).then((materials) => ({ built, materials })); + }) + .then(({ built, materials }) => { + for (let i = 0; i < built.cubes.length; i++) { + const cube = built.cubes[i]; const geometry = buildGeometry(cube.size, cube.uv, built.texture_width, built.texture_height); - const mesh = new THREE.Mesh(geometry, materialFor(cube.texture, baseUrl)); + let material = materials[i]; + if (!material) { + material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 }); + } + const mesh = new THREE.Mesh(geometry, material); mesh.matrix = new THREE.Matrix4().fromArray(cube.matrix); mesh.matrixAutoUpdate = false; meshRoot.add(mesh); diff --git a/nonpacks/static/js/tests/geo_builder.test.js b/nonpacks/static/js/tests/geo_builder.test.js index 90b294f..303fb9b 100644 --- a/nonpacks/static/js/tests/geo_builder.test.js +++ b/nonpacks/static/js/tests/geo_builder.test.js @@ -80,8 +80,12 @@ for (const { c } of centers) { } assert(allInRange, 'all UVs within [0,1]'); -// Unmapped bones default to the pack's main texture. -assert(headCube.texture === 'needsofnature:textures/entity/zombie/zombie.png', 'head uses default texture'); +// Unmapped bones default to the pack's main texture (resource location +// converted to the zip member path). +assert(headCube.texture === 'assets/needsofnature/textures/entity/zombie/zombie.png', 'head uses default texture'); +assert(GeoBuilder.resourceToMember('needsofnature:textures/entity/zombie/zombie.png') === 'assets/needsofnature/textures/entity/zombie/zombie.png', 'resource location converted to member'); +assert(GeoBuilder.resourceToMember('assets/needsofnature/textures/x.png') === 'assets/needsofnature/textures/x.png', 'already-member unchanged'); +assert(GeoBuilder.resourceToMember('textures/plain.png') === 'assets/textures/plain.png', 'bare path prefixed'); // ---------- 2. Synthetic rotated bone: Rz(-90°) around pivot (0,10,0) ---------- // Rotations are negated to match the Bedrock/GeckoLib direction. @@ -166,6 +170,61 @@ if (pFront) { assert(approx(pRear.center[1], pFront.center[1], 1.5), 'rear + front torso at similar height'); } +// ---------- 6. Per-face UV (Blockbench) cubes produce finite, in-range UVs ---------- +// Real per-face cube from the default pack phantom model (down face is flipped +// via negative uv_size). +const perFace = { + 'minecraft:geometry': [{ + description: { identifier: 'geometry.pf', texture_width: 64, texture_height: 64 }, + bones: [{ + name: 'd2', + cubes: [{ + origin: [0, 0, 0], size: [4, 8, 4], + uv: { + north: { uv: [8, 40], uv_size: [4, 8] }, + east: { uv: [0, 40], uv_size: [4, 8] }, + south: { uv: [12, 40], uv_size: [4, 8] }, + west: { uv: [4, 40], uv_size: [4, 8] }, + up: { uv: [4, 36], uv_size: [4, 4] }, + down: { uv: [8, 40], uv_size: [4, -4] }, + }, + }], + }], + }], +}; +const pfBuilt = GeoBuilder.build(perFace); +const pfCube = pfBuilt.cubes[0]; +assert(pfCube && typeof pfCube.uv === 'object', 'per-face cube passed through build()'); +if (pfCube) { + const g = GeoBuilder.cubeGeometry(pfCube.size, pfCube.uv, 64, 64); + assert(g.uvs.length === 48, 'per-face geometry emits 24 UVs (48 floats)'); + let allFinite = true, allIn01 = true; + for (const u of g.uvs) { + if (!Number.isFinite(u)) allFinite = false; + if (u < 0 || u > 1) allIn01 = false; + } + assert(allFinite, 'per-face UVs are finite (no NaN)'); + assert(allIn01, 'per-face UVs within [0,1]'); + // south face = index 4 in face order [east,west,top,bottom,south,north]: + // rect [12,40,4,8] -> U 12/64..16/64, V flipped: 1-(40+8)/64 .. 1-40/64. + const southStart = 4 * 8; + let minU = 2, maxU = -1, minV = 2, maxV = -1; + for (let i = 0; i < 8; i += 2) { + minU = Math.min(minU, g.uvs[southStart + i]); + maxU = Math.max(maxU, g.uvs[southStart + i]); + minV = Math.min(minV, g.uvs[southStart + i + 1]); + maxV = Math.max(maxV, g.uvs[southStart + i + 1]); + } + assert(approx(minU, 12 / 64) && approx(maxU, 16 / 64), `per-face south U in (12..16)/64 (got ${minU.toFixed(3)}..${maxU.toFixed(3)})`); + assert(approx(minV, 1 - 48 / 64) && approx(maxV, 1 - 40 / 64), `per-face south V in [0.25,0.375] (got ${minV.toFixed(3)}..${maxV.toFixed(3)})`); +} + +// ---------- 7. Per-face rects: negative uv_size mirrors (down face) ---------- +const rects = GeoBuilder.perFaceRects(perFace['minecraft:geometry'][0].bones[0].cubes[0].uv); +assert(rects.bottom[3] === -4, 'negative uv_size preserved for down face'); +assert(rects.top[3] === 4 && rects.south[2] === 4, 'positive faces keep size'); +assert(Array.isArray(GeoBuilder.boxUVRects([0, 0], [4, 8, 4]).south), 'boxUVRects still array-based'); + // ---------- summary ---------- if (failures === 0) { console.log(`geo_builder tests OK (${checks} checks)`); diff --git a/nonpacks/templates/library/project_detail.html b/nonpacks/templates/library/project_detail.html index 5d1d4d6..208f200 100644 --- a/nonpacks/templates/library/project_detail.html +++ b/nonpacks/templates/library/project_detail.html @@ -242,7 +242,9 @@ {{ model.name }} + data-default-texture="{{ model.default_texture }}" + data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}" + data-vanilla-base-url="{% url 'library:vanilla_asset' 'ASSET' %}"> Render

{{ model.bones }} bones · {{ model.cubes }} cubes

@@ -518,7 +520,11 @@ window.PacksModelViewer.render( btn.dataset.member, container, - btn.dataset.baseUrl + { + baseUrl: btn.dataset.baseUrl, + vanillaBaseUrl: btn.dataset.vanillaBaseUrl, + defaultTexture: btn.dataset.defaultTexture || null, + } ); document.getElementById('model-modal').hidden = false; });