backup befor new branch to test Blockbenh instead of regex renderer
This commit is contained in:
@@ -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.'))
|
||||||
@@ -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>/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>/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('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>/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/upload/', views.asset_upload, name='asset_upload'),
|
||||||
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
|
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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):
|
def guide_doc(request, slug, version_id, file_uuid):
|
||||||
"""Render one markdown guide document server-side (used by the switcher)."""
|
"""Render one markdown guide document server-side (used by the switcher)."""
|
||||||
version = get_object_or_404(
|
version = get_object_or_404(
|
||||||
|
|||||||
@@ -275,10 +275,29 @@ def read_models_manifest(path):
|
|||||||
for bone in geom.get('bones') or []:
|
for bone in geom.get('bones') or []:
|
||||||
bone_count += 1
|
bone_count += 1
|
||||||
cube_count += len(bone.get('cubes') or [])
|
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({
|
models.append({
|
||||||
'name': name.rsplit('/', 1)[-1],
|
'name': name.rsplit('/', 1)[-1],
|
||||||
'member': name,
|
'member': name,
|
||||||
'identifier': identifier,
|
'identifier': identifier,
|
||||||
|
'entity': entity,
|
||||||
|
'default_texture': default_texture,
|
||||||
'bones': bone_count,
|
'bones': bone_count,
|
||||||
'cubes': cube_count,
|
'cubes': cube_count,
|
||||||
'bone_textures': bone_textures,
|
'bone_textures': bone_textures,
|
||||||
|
|||||||
@@ -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:
|
// Build one cube as a BufferGeometry-compatible mesh:
|
||||||
// 24 positions (6 faces x 4 corners), 24 UVs, 36 indices, outward winding.
|
// 24 positions (6 faces x 4 corners), 24 UVs, 36 indices, outward winding.
|
||||||
function cubeGeometry(size, uv, tw, th) {
|
function cubeGeometry(size, uv, tw, th) {
|
||||||
var hx = size[0] / 2, hy = size[1] / 2, hz = size[2] / 2;
|
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 order = ['east', 'west', 'top', 'bottom', 'south', 'north'];
|
||||||
var outward = {
|
var outward = {
|
||||||
east: [1, 0, 0], west: [-1, 0, 0],
|
east: [1, 0, 0], west: [-1, 0, 0],
|
||||||
@@ -167,13 +185,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- model build ----------
|
// ---------- 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 }
|
// Returns { cubes: [{matrix, size, uv, texture}], texture_width, texture_height }
|
||||||
function build(geo) {
|
function build(geo) {
|
||||||
var geometry = ((geo['minecraft:geometry'] || [])[0]) || null;
|
var geometry = ((geo['minecraft:geometry'] || [])[0]) || null;
|
||||||
if (!geometry) return { cubes: [], texture_width: 64, texture_height: 64 };
|
if (!geometry) return { cubes: [], texture_width: 64, texture_height: 64 };
|
||||||
var tw = (geometry.description && geometry.description.texture_width) || 64;
|
var tw = (geometry.description && geometry.description.texture_width) || 64;
|
||||||
var th = (geometry.description && geometry.description.texture_height) || 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;
|
var defaultTex = null;
|
||||||
for (var k in textures) { defaultTex = textures[k]; break; }
|
for (var k in textures) { defaultTex = textures[k]; break; }
|
||||||
|
|
||||||
@@ -247,6 +280,8 @@
|
|||||||
build: build,
|
build: build,
|
||||||
cubeGeometry: cubeGeometry,
|
cubeGeometry: cubeGeometry,
|
||||||
boxUVRects: boxUVRects,
|
boxUVRects: boxUVRects,
|
||||||
|
perFaceRects: perFaceRects,
|
||||||
|
resourceToMember: resourceToMember,
|
||||||
transformPoint: transformPoint,
|
transformPoint: transformPoint,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
translation: translation,
|
translation: translation,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
// GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder
|
// GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder
|
||||||
// geometry math (bone transforms + box-UV) and renders world-space cubes with
|
// geometry math (bone transforms + box/per-face UV) and renders world-space
|
||||||
// Three.js. Textures are served through the gated pack_asset endpoint.
|
// 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 () {
|
(function () {
|
||||||
let renderer = null;
|
let renderer = null;
|
||||||
let scene = null;
|
let scene = null;
|
||||||
@@ -9,32 +15,117 @@
|
|||||||
let rafId = null;
|
let rafId = null;
|
||||||
const textureLoader = new THREE.TextureLoader();
|
const textureLoader = new THREE.TextureLoader();
|
||||||
const materialCache = {};
|
const materialCache = {};
|
||||||
|
let vanillaIndex = null;
|
||||||
|
let vanillaIndexPromise = null;
|
||||||
|
|
||||||
function assetUrl(baseUrl, member) {
|
function assetUrl(baseUrl, member) {
|
||||||
return baseUrl.replace('ASSET', 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) {
|
function buildGeometry(size, uv, tw, th) {
|
||||||
const g = GeoBuilder.cubeGeometry(size, uv, tw, th);
|
const g = GeoBuilder.cubeGeometry(size, uv, tw, th);
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(g.positions, 3));
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(g.positions, 3));
|
||||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(g.uvs, 2));
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(g.uvs, 2));
|
||||||
geometry.setIndex(g.indices);
|
geometry.setIndex(g.indices);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function materialFor(member, baseUrl) {
|
function fetchVanillaIndex(opts) {
|
||||||
if (materialCache[member || '__gray__']) return materialCache[member || '__gray__'];
|
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;
|
let material;
|
||||||
if (member) {
|
if (texture) {
|
||||||
const texture = textureLoader.load(assetUrl(baseUrl, member));
|
|
||||||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.9, metalness: 0.0 });
|
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 {
|
} else {
|
||||||
material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 });
|
material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 });
|
||||||
}
|
}
|
||||||
materialCache[member || '__gray__'] = material;
|
materialCache[key] = material;
|
||||||
return material;
|
return material;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +140,7 @@
|
|||||||
for (const key in materialCache) delete materialCache[key];
|
for (const key in materialCache) delete materialCache[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(member, container, baseUrl) {
|
function render(member, container, opts) {
|
||||||
if (renderer) dispose();
|
if (renderer) dispose();
|
||||||
const width = container.clientWidth || 480;
|
const width = container.clientWidth || 480;
|
||||||
const height = container.clientHeight || 480;
|
const height = container.clientHeight || 480;
|
||||||
@@ -60,7 +151,7 @@
|
|||||||
camera.position.set(0, 20, 60);
|
camera.position.set(0, 20, 60);
|
||||||
camera.lookAt(0, 12, 0);
|
camera.lookAt(0, 12, 0);
|
||||||
|
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||||
renderer.setSize(width, height);
|
renderer.setSize(width, height);
|
||||||
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||||
container.appendChild(renderer.domElement);
|
container.appendChild(renderer.domElement);
|
||||||
@@ -76,14 +167,29 @@
|
|||||||
meshRoot = new THREE.Group();
|
meshRoot = new THREE.Group();
|
||||||
scene.add(meshRoot);
|
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(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);
|
const built = GeoBuilder.build(geo);
|
||||||
if (!built.cubes.length) throw new Error('no cubes');
|
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 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.matrix = new THREE.Matrix4().fromArray(cube.matrix);
|
||||||
mesh.matrixAutoUpdate = false;
|
mesh.matrixAutoUpdate = false;
|
||||||
meshRoot.add(mesh);
|
meshRoot.add(mesh);
|
||||||
|
|||||||
@@ -80,8 +80,12 @@ for (const { c } of centers) {
|
|||||||
}
|
}
|
||||||
assert(allInRange, 'all UVs within [0,1]');
|
assert(allInRange, 'all UVs within [0,1]');
|
||||||
|
|
||||||
// Unmapped bones default to the pack's main texture.
|
// Unmapped bones default to the pack's main texture (resource location
|
||||||
assert(headCube.texture === 'needsofnature:textures/entity/zombie/zombie.png', 'head uses default texture');
|
// 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) ----------
|
// ---------- 2. Synthetic rotated bone: Rz(-90°) around pivot (0,10,0) ----------
|
||||||
// Rotations are negated to match the Bedrock/GeckoLib direction.
|
// 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');
|
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 ----------
|
// ---------- summary ----------
|
||||||
if (failures === 0) {
|
if (failures === 0) {
|
||||||
console.log(`geo_builder tests OK (${checks} checks)`);
|
console.log(`geo_builder tests OK (${checks} checks)`);
|
||||||
|
|||||||
@@ -242,7 +242,9 @@
|
|||||||
<strong>{{ model.name }}</strong>
|
<strong>{{ model.name }}</strong>
|
||||||
<button type="button" class="btn btn-secondary btn-sm model-render-btn"
|
<button type="button" class="btn btn-secondary btn-sm model-render-btn"
|
||||||
data-member="{{ model.member }}" data-name="{{ model.name }}"
|
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>
|
</div>
|
||||||
<p class="model-meta">{{ model.bones }} bones · {{ model.cubes }} cubes</p>
|
<p class="model-meta">{{ model.bones }} bones · {{ model.cubes }} cubes</p>
|
||||||
<div class="model-textures">
|
<div class="model-textures">
|
||||||
@@ -518,7 +520,11 @@
|
|||||||
window.PacksModelViewer.render(
|
window.PacksModelViewer.render(
|
||||||
btn.dataset.member,
|
btn.dataset.member,
|
||||||
container,
|
container,
|
||||||
btn.dataset.baseUrl
|
{
|
||||||
|
baseUrl: btn.dataset.baseUrl,
|
||||||
|
vanillaBaseUrl: btn.dataset.vanillaBaseUrl,
|
||||||
|
defaultTexture: btn.dataset.defaultTexture || null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
document.getElementById('model-modal').hidden = false;
|
document.getElementById('model-modal').hidden = false;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user