backup befor new branch to test Blockbenh instead of regex renderer

This commit is contained in:
JakeBreath
2026-08-04 16:04:30 -05:00
parent f59e9a165c
commit 8c17c273fe
11 changed files with 376 additions and 19 deletions
@@ -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.'))
+1
View File
@@ -15,6 +15,7 @@ urlpatterns = [
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
path('packs/<slug:slug>/versions/<int:version_id>/asset/<path:asset_path>', views.pack_asset, name='pack_asset'),
path('vanilla/<path:asset_path>', views.vanilla_asset, name='vanilla_asset'),
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
+107
View File
@@ -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
+13
View File
@@ -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(
+19
View File
@@ -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,
+37 -2
View File
@@ -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,
+119 -13
View File
@@ -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);
+61 -2
View File
@@ -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)`);
@@ -242,7 +242,9 @@
<strong>{{ model.name }}</strong>
<button type="button" class="btn btn-secondary btn-sm model-render-btn"
data-member="{{ model.member }}" data-name="{{ model.name }}"
data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}"><i class="fas fa-cube"></i> Render</button>
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' %}"><i class="fas fa-cube"></i> Render</button>
</div>
<p class="model-meta">{{ model.bones }} bones · {{ model.cubes }} cubes</p>
<div class="model-textures">
@@ -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;
});