Merge branch 'blockbench_renderer' into 'master'
Blockbench renderer See merge request JakeBreath/NoN-Packs-Sit!1
@@ -5,5 +5,6 @@ Packs_DB
|
|||||||
.migrations_done
|
.migrations_done
|
||||||
nonpacks/staticfiles/
|
nonpacks/staticfiles/
|
||||||
nonpacks/media/
|
nonpacks/media/
|
||||||
|
nonpacks/static/vanilla/entity_index.json
|
||||||
*.log
|
*.log
|
||||||
AGENTS/
|
AGENTS/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from library.vanilla_textures import refresh_vanilla_index
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Rebuild the vanilla entity-texture index (media/vanilla/entity_index.json).'
|
help = 'Rebuild the vanilla entity-texture index (static/vanilla/entity_index.json).'
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
index = refresh_vanilla_index()
|
index = refresh_vanilla_index()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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'),
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Build the vanilla entity-texture index for the model viewer.
|
"""Build the vanilla entity-texture index for the model viewer.
|
||||||
|
|
||||||
Scans ``MEDIA_ROOT/vanilla/entity/`` (extracted from a Minecraft client jar)
|
Scans ``static/vanilla/entity/`` (extracted from a Minecraft client jar,
|
||||||
and writes ``MEDIA_ROOT/vanilla/entity_index.json`` mapping each entity name to
|
tracked in git) and writes ``static/vanilla/entity_index.json`` mapping each
|
||||||
the best "base skin" relative path, so the renderer can fall back to real
|
entity name to the best "base skin" relative path, so the renderer can fall
|
||||||
vanilla textures for models that have no pack textures of their own.
|
back to real vanilla textures for models that have no pack textures of their
|
||||||
|
own.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -13,6 +14,11 @@ from django.conf import settings
|
|||||||
ENTITY_ROOT = 'entity'
|
ENTITY_ROOT = 'entity'
|
||||||
INDEX_NAME = 'entity_index.json'
|
INDEX_NAME = 'entity_index.json'
|
||||||
|
|
||||||
|
|
||||||
|
def vanilla_dir():
|
||||||
|
"""The tracked static directory holding the vanilla texture set."""
|
||||||
|
return os.path.join(settings.BASE_DIR, 'static', 'vanilla')
|
||||||
|
|
||||||
# Entity names whose vanilla layout differs from the model-name convention.
|
# Entity names whose vanilla layout differs from the model-name convention.
|
||||||
ALIASES = {
|
ALIASES = {
|
||||||
'polar_bear': 'bear/polarbear.png',
|
'polar_bear': 'bear/polarbear.png',
|
||||||
@@ -50,12 +56,32 @@ _LAYER_KEYWORDS = (
|
|||||||
'_fur', '_shell', '_crackiness',
|
'_fur', '_shell', '_crackiness',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Entities whose base skin is one of several fur/colour variants. The preview
|
||||||
|
# picks one at random so the model matches the variety the pack shows in-game.
|
||||||
|
VARIANTS = {
|
||||||
|
'wolf': [
|
||||||
|
'wolf/wolf.png', 'wolf/wolf_ashen.png', 'wolf/wolf_black.png',
|
||||||
|
'wolf/wolf_chestnut.png', 'wolf/wolf_rusty.png', 'wolf/wolf_snowy.png',
|
||||||
|
'wolf/wolf_spotted.png', 'wolf/wolf_striped.png', 'wolf/wolf_woods.png',
|
||||||
|
],
|
||||||
|
'fox': ['fox/fox.png', 'fox/snow_fox.png'],
|
||||||
|
'cat': [
|
||||||
|
'cat/tabby.png', 'cat/black.png', 'cat/red.png', 'cat/siamese.png',
|
||||||
|
'cat/british_shorthair.png', 'cat/calico.png', 'cat/persian.png',
|
||||||
|
'cat/ragdoll.png', 'cat/white.png', 'cat/jellie.png', 'cat/all_black.png',
|
||||||
|
],
|
||||||
|
'cow': [
|
||||||
|
'cow/temperate_cow.png', 'cow/cold_cow.png', 'cow/warm_cow.png',
|
||||||
|
'cow/red_mooshroom.png', 'cow/brown_mooshroom.png',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _base_candidates(entity):
|
def _base_candidates(entity):
|
||||||
"""Return (entity, [candidate relative paths]) in priority order."""
|
"""Return (entity, [candidate relative paths]) in priority order."""
|
||||||
if entity in ALIASES:
|
if entity in ALIASES:
|
||||||
return entity, [ALIASES[entity]]
|
return entity, [ALIASES[entity]]
|
||||||
root = os.path.join(settings.MEDIA_ROOT, 'vanilla', ENTITY_ROOT)
|
root = os.path.join(vanilla_dir(), ENTITY_ROOT)
|
||||||
top = os.path.join(root, entity + '.png')
|
top = os.path.join(root, entity + '.png')
|
||||||
if os.path.isfile(top):
|
if os.path.isfile(top):
|
||||||
return entity, [entity + '.png']
|
return entity, [entity + '.png']
|
||||||
@@ -77,8 +103,8 @@ def _base_candidates(entity):
|
|||||||
|
|
||||||
|
|
||||||
def refresh_vanilla_index():
|
def refresh_vanilla_index():
|
||||||
"""Rebuild the entity → base-skin map from the extracted vanilla set."""
|
"""Rebuild the entity → {default, variants} skin map from the vanilla set."""
|
||||||
root = os.path.join(settings.MEDIA_ROOT, 'vanilla', ENTITY_ROOT)
|
root = os.path.join(vanilla_dir(), ENTITY_ROOT)
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
raise FileNotFoundError(f'{root} missing — extract vanilla entity textures first')
|
raise FileNotFoundError(f'{root} missing — extract vanilla entity textures first')
|
||||||
|
|
||||||
@@ -87,21 +113,25 @@ def refresh_vanilla_index():
|
|||||||
rel = os.path.join(root, entry)
|
rel = os.path.join(root, entry)
|
||||||
if entry.endswith('.png'):
|
if entry.endswith('.png'):
|
||||||
entity = entry[:-4]
|
entity = entry[:-4]
|
||||||
if not index.get(entity):
|
index.setdefault(entity, {'default': entry, 'variants': [entry]})
|
||||||
index[entity] = entry
|
|
||||||
continue
|
continue
|
||||||
if not os.path.isdir(rel):
|
if not os.path.isdir(rel):
|
||||||
continue
|
continue
|
||||||
entity = entry
|
entity = entry
|
||||||
_, candidates = _base_candidates(entity)
|
_, candidates = _base_candidates(entity)
|
||||||
if candidates and not index.get(entity):
|
if candidates and entity not in index:
|
||||||
index[entity] = candidates[0]
|
index[entity] = {'default': candidates[0], 'variants': [candidates[0]]}
|
||||||
|
|
||||||
for entity, rel in ALIASES.items():
|
for entity, rel in ALIASES.items():
|
||||||
if not index.get(entity):
|
if entity not in index:
|
||||||
index[entity] = rel
|
index[entity] = {'default': rel, 'variants': [rel]}
|
||||||
|
|
||||||
out = os.path.join(settings.MEDIA_ROOT, 'vanilla', INDEX_NAME)
|
for entity, variants in VARIANTS.items():
|
||||||
|
if entity in index and variants:
|
||||||
|
index[entity]['default'] = variants[0]
|
||||||
|
index[entity]['variants'] = variants
|
||||||
|
|
||||||
|
out = os.path.join(vanilla_dir(), INDEX_NAME)
|
||||||
with open(out, 'w') as f:
|
with open(out, 'w') as f:
|
||||||
json.dump(index, f, indent=1, sort_keys=True)
|
json.dump(index, f, indent=1, sort_keys=True)
|
||||||
return index
|
return index
|
||||||
|
|||||||
@@ -423,19 +423,6 @@ 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(
|
||||||
|
|||||||
@@ -3268,3 +3268,64 @@ a.deletelink {
|
|||||||
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
color: var(--md-sys-color-on-error-container, #7f1d1d);
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-screen Blockbench preview overlay */
|
||||||
|
.bb-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
background: #121418;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.bb-overlay[hidden] { display: none; }
|
||||||
|
.bb-overlay-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #1a1d24;
|
||||||
|
color: #e6e6e6;
|
||||||
|
border-bottom: 1px solid #2a2e38;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.bb-overlay-title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.bb-overlay-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e6e6e6;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.bb-overlay-close:hover { background: #2a2e38; }
|
||||||
|
.bb-overlay-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 3.1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 80%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.bb-frame {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: #121418;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
// Pure geometry math for GeckoLib/Blockbench geo.json models.
|
|
||||||
// No Three.js dependency — shared by model_viewer.js (browser) and unit tests (Node).
|
|
||||||
//
|
|
||||||
// Transforms follow the GeckoLib convention: each bone contributes
|
|
||||||
// local = T(pivot) · R(bone.rotation) · T(−pivot)
|
|
||||||
// and cubes are authored in model space (origin at feet, Y up).
|
|
||||||
// `build()` flattens the bone tree into world-space cubes, avoiding the
|
|
||||||
// nested-pivot double-counting that broke naive group-based renderers.
|
|
||||||
(function (global, factory) {
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = factory();
|
|
||||||
} else {
|
|
||||||
global.GeoBuilder = factory();
|
|
||||||
}
|
|
||||||
})(typeof self !== 'undefined' ? self : this, function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var DEG = Math.PI / 180;
|
|
||||||
|
|
||||||
// ---------- minimal 4x4 matrix helpers (column-major, Three.js order) ----------
|
|
||||||
function identity() {
|
|
||||||
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function multiply(a, b) {
|
|
||||||
var out = new Array(16);
|
|
||||||
for (var c = 0; c < 4; c++) {
|
|
||||||
for (var r = 0; r < 4; r++) {
|
|
||||||
out[c * 4 + r] =
|
|
||||||
a[0 * 4 + r] * b[c * 4 + 0] +
|
|
||||||
a[1 * 4 + r] * b[c * 4 + 1] +
|
|
||||||
a[2 * 4 + r] * b[c * 4 + 2] +
|
|
||||||
a[3 * 4 + r] * b[c * 4 + 3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function translation(x, y, z) {
|
|
||||||
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotationXYZ(rx, ry, rz) {
|
|
||||||
var cx = Math.cos(rx), sx = Math.sin(rx);
|
|
||||||
var cy = Math.cos(ry), sy = Math.sin(ry);
|
|
||||||
var cz = Math.cos(rz), sz = Math.sin(rz);
|
|
||||||
// R = Rz * Ry * Rx (matches Three.js Euler 'XYZ' default order)
|
|
||||||
var r = identity();
|
|
||||||
r[0] = cy * cz;
|
|
||||||
r[1] = cy * sz;
|
|
||||||
r[2] = -sy;
|
|
||||||
r[4] = sx * sy * cz - cx * sz;
|
|
||||||
r[5] = sx * sy * sz + cx * cz;
|
|
||||||
r[6] = sx * cy;
|
|
||||||
r[8] = cx * sy * cz + sx * sz;
|
|
||||||
r[9] = cx * sy * sz - sx * cz;
|
|
||||||
r[10] = cx * cy;
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
function transformPoint(m, p) {
|
|
||||||
var x = p[0], y = p[1], z = p[2];
|
|
||||||
var w = m[3] * x + m[7] * y + m[11] * z + m[15];
|
|
||||||
return [
|
|
||||||
(m[0] * x + m[4] * y + m[8] * z + m[12]) / w,
|
|
||||||
(m[1] * x + m[5] * y + m[9] * z + m[13]) / w,
|
|
||||||
(m[2] * x + m[6] * y + m[10] * z + m[14]) / w,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- box UV ----------
|
|
||||||
// Minecraft "box UV" face rects (u, v, w, h) in texture pixels.
|
|
||||||
function boxUVRects(uv, size) {
|
|
||||||
var u = uv[0], v = uv[1];
|
|
||||||
var x = size[0], y = size[1], z = size[2];
|
|
||||||
return {
|
|
||||||
top: [u + z, v, x, z],
|
|
||||||
bottom: [u + z + x, v, x, z],
|
|
||||||
north: [u + z, v + z, x, y],
|
|
||||||
south: [u + z + x, v + z, x, y],
|
|
||||||
east: [u, v + z, z, y],
|
|
||||||
west: [u + z + x + z, v + z, z, y],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = 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],
|
|
||||||
top: [0, 1, 0], bottom: [0, -1, 0],
|
|
||||||
south: [0, 0, 1], north: [0, 0, -1],
|
|
||||||
};
|
|
||||||
|
|
||||||
var positions = [];
|
|
||||||
var uvs = [];
|
|
||||||
var indices = [];
|
|
||||||
var v = 0;
|
|
||||||
|
|
||||||
function pushFace(face, corners) {
|
|
||||||
var a = corners[0], b = corners[1], c = corners[2];
|
|
||||||
var abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2];
|
|
||||||
var acx = c[0] - a[0], acy = c[1] - a[1], acz = c[2] - a[2];
|
|
||||||
var nx = aby * acz - abz * acy;
|
|
||||||
var ny = abz * acx - abx * acz;
|
|
||||||
var nz = abx * acy - aby * acx;
|
|
||||||
var out = outward[face];
|
|
||||||
if (nx * out[0] + ny * out[1] + nz * out[2] < 0) {
|
|
||||||
var tmp = corners[1];
|
|
||||||
corners[1] = corners[2];
|
|
||||||
corners[2] = tmp;
|
|
||||||
}
|
|
||||||
for (var i = 0; i < 4; i++) {
|
|
||||||
var cor = corners[i];
|
|
||||||
positions.push(cor[0], cor[1], cor[2]);
|
|
||||||
uvs.push(cor[3], cor[4]);
|
|
||||||
}
|
|
||||||
indices.push(v, v + 1, v + 2, v, v + 2, v + 3);
|
|
||||||
v += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < order.length; i++) {
|
|
||||||
var face = order[i];
|
|
||||||
var rect = rects[face];
|
|
||||||
var u0 = rect[0] / tw, u1 = (rect[0] + rect[2]) / tw;
|
|
||||||
var vTop = 1 - (rect[1] + rect[3]) / th;
|
|
||||||
var vBot = 1 - rect[1] / th;
|
|
||||||
var cs;
|
|
||||||
if (face === 'east') {
|
|
||||||
cs = [
|
|
||||||
[hx, hy, -hz, u1, vTop], [hx, hy, hz, u0, vTop],
|
|
||||||
[hx, -hy, hz, u0, vBot], [hx, -hy, -hz, u1, vBot],
|
|
||||||
];
|
|
||||||
} else if (face === 'west') {
|
|
||||||
cs = [
|
|
||||||
[-hx, hy, hz, u1, vTop], [-hx, hy, -hz, u0, vTop],
|
|
||||||
[-hx, -hy, -hz, u0, vBot], [-hx, -hy, hz, u1, vBot],
|
|
||||||
];
|
|
||||||
} else if (face === 'top') {
|
|
||||||
cs = [
|
|
||||||
[-hx, hy, hz, u0, vTop], [hx, hy, hz, u1, vTop],
|
|
||||||
[hx, hy, -hz, u1, vBot], [-hx, hy, -hz, u0, vBot],
|
|
||||||
];
|
|
||||||
} else if (face === 'bottom') {
|
|
||||||
cs = [
|
|
||||||
[-hx, -hy, -hz, u0, vTop], [hx, -hy, -hz, u1, vTop],
|
|
||||||
[hx, -hy, hz, u1, vBot], [-hx, -hy, hz, u0, vBot],
|
|
||||||
];
|
|
||||||
} else if (face === 'south') {
|
|
||||||
cs = [
|
|
||||||
[hx, hy, hz, u0, vTop], [-hx, hy, hz, u1, vTop],
|
|
||||||
[-hx, -hy, hz, u1, vBot], [hx, -hy, hz, u0, vBot],
|
|
||||||
];
|
|
||||||
} else { // north
|
|
||||||
cs = [
|
|
||||||
[-hx, hy, -hz, u0, vTop], [hx, hy, -hz, u1, vTop],
|
|
||||||
[hx, -hy, -hz, u1, vBot], [-hx, -hy, -hz, u0, vBot],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
pushFace(face, cs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { positions: positions, uvs: uvs, indices: indices };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 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 = {};
|
|
||||||
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; }
|
|
||||||
|
|
||||||
var bones = {};
|
|
||||||
(geometry.bones || []).forEach(function (b) { bones[b.name] = b; });
|
|
||||||
|
|
||||||
var cubes = [];
|
|
||||||
function walk(name, parentMatrix) {
|
|
||||||
var bone = bones[name];
|
|
||||||
if (!bone) return;
|
|
||||||
var pivot = bone.pivot || [0, 0, 0];
|
|
||||||
// Bedrock/GeckoLib rotations are opposite the Three.js default
|
|
||||||
// direction, so negate the Euler angles (identity models unaffected).
|
|
||||||
var rot = (bone.rotation || [0, 0, 0]).map(function (d) { return -d * DEG; });
|
|
||||||
var local = multiply(
|
|
||||||
translation(pivot[0], pivot[1], pivot[2]),
|
|
||||||
multiply(
|
|
||||||
rotationXYZ(rot[0], rot[1], rot[2]),
|
|
||||||
translation(-pivot[0], -pivot[1], -pivot[2])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
var world = multiply(parentMatrix, local);
|
|
||||||
var tex = textures[name] || defaultTex;
|
|
||||||
|
|
||||||
(bone.cubes || []).forEach(function (c) {
|
|
||||||
var origin = c.origin || [0, 0, 0];
|
|
||||||
var size = c.size || [1, 1, 1];
|
|
||||||
var cRot = (c.rotation || [0, 0, 0]).map(function (d) { return -d * DEG; });
|
|
||||||
var hasRot = !!(c.rotation && (c.rotation[0] || c.rotation[1] || c.rotation[2]));
|
|
||||||
// Blockbench cubes rotate around their own pivot (fall back to origin).
|
|
||||||
var cPivot = hasRot ? (c.pivot || origin) : origin;
|
|
||||||
var center = [
|
|
||||||
origin[0] + size[0] / 2,
|
|
||||||
origin[1] + size[1] / 2,
|
|
||||||
origin[2] + size[2] / 2,
|
|
||||||
];
|
|
||||||
var cubeWorld = multiply(
|
|
||||||
world,
|
|
||||||
multiply(
|
|
||||||
translation(cPivot[0], cPivot[1], cPivot[2]),
|
|
||||||
multiply(
|
|
||||||
rotationXYZ(cRot[0], cRot[1], cRot[2]),
|
|
||||||
multiply(
|
|
||||||
translation(-cPivot[0], -cPivot[1], -cPivot[2]),
|
|
||||||
translation(center[0], center[1], center[2])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
cubes.push({
|
|
||||||
matrix: cubeWorld,
|
|
||||||
size: size,
|
|
||||||
uv: c.uv || [0, 0],
|
|
||||||
texture: tex,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
for (var child in bones) {
|
|
||||||
if (bones[child].parent === name) walk(child, world);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var root in bones) {
|
|
||||||
if (!bones[root].parent) walk(root, identity());
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cubes: cubes, texture_width: tw, texture_height: th };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
build: build,
|
|
||||||
cubeGeometry: cubeGeometry,
|
|
||||||
boxUVRects: boxUVRects,
|
|
||||||
perFaceRects: perFaceRects,
|
|
||||||
resourceToMember: resourceToMember,
|
|
||||||
transformPoint: transformPoint,
|
|
||||||
identity: identity,
|
|
||||||
translation: translation,
|
|
||||||
rotationXYZ: rotationXYZ,
|
|
||||||
multiply: multiply,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
// GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder
|
|
||||||
// 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;
|
|
||||||
let camera = null;
|
|
||||||
let meshRoot = null;
|
|
||||||
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 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 (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[key] = material;
|
|
||||||
return material;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disposeObject(obj) {
|
|
||||||
obj.traverse((node) => {
|
|
||||||
if (node.geometry) node.geometry.dispose();
|
|
||||||
});
|
|
||||||
for (const key in materialCache) {
|
|
||||||
if (materialCache[key].map) materialCache[key].map.dispose();
|
|
||||||
materialCache[key].dispose();
|
|
||||||
}
|
|
||||||
for (const key in materialCache) delete materialCache[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(member, container, opts) {
|
|
||||||
if (renderer) dispose();
|
|
||||||
const width = container.clientWidth || 480;
|
|
||||||
const height = container.clientHeight || 480;
|
|
||||||
|
|
||||||
scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0x1e1e2e);
|
|
||||||
camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 2000);
|
|
||||||
camera.position.set(0, 20, 60);
|
|
||||||
camera.lookAt(0, 12, 0);
|
|
||||||
|
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
|
||||||
renderer.setSize(width, height);
|
|
||||||
renderer.setPixelRatio(window.devicePixelRatio || 1);
|
|
||||||
container.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
||||||
const key = new THREE.DirectionalLight(0xffffff, 0.9);
|
|
||||||
key.position.set(30, 60, 30);
|
|
||||||
scene.add(key);
|
|
||||||
const fill = new THREE.DirectionalLight(0xffffff, 0.3);
|
|
||||||
fill.position.set(-30, 20, -30);
|
|
||||||
scene.add(fill);
|
|
||||||
|
|
||||||
meshRoot = new THREE.Group();
|
|
||||||
scene.add(meshRoot);
|
|
||||||
|
|
||||||
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 => Promise.all([geo, fetchVanillaIndex(opts)]))
|
|
||||||
.then(([geo, index]) => {
|
|
||||||
const built = GeoBuilder.build(geo);
|
|
||||||
if (!built.cubes.length) throw new Error('no 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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const box = new THREE.Box3().setFromObject(meshRoot);
|
|
||||||
const center = box.getCenter(new THREE.Vector3());
|
|
||||||
const size = box.getSize(new THREE.Vector3());
|
|
||||||
const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
|
|
||||||
meshRoot.position.sub(center);
|
|
||||||
camera.position.set(radius * 2.2, radius * 1.6, radius * 2.6);
|
|
||||||
camera.near = radius / 10;
|
|
||||||
camera.far = radius * 40;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
container.innerHTML = '<p class="empty-hint">Could not load this model.</p>';
|
|
||||||
});
|
|
||||||
|
|
||||||
let isDragging = false;
|
|
||||||
let lastX = 0, lastY = 0;
|
|
||||||
renderer.domElement.addEventListener('mousedown', (e) => {
|
|
||||||
isDragging = true; lastX = e.clientX; lastY = e.clientY;
|
|
||||||
});
|
|
||||||
window.addEventListener('mouseup', () => { isDragging = false; });
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
|
||||||
if (!isDragging || !meshRoot) return;
|
|
||||||
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
|
||||||
lastX = e.clientX; lastY = e.clientY;
|
|
||||||
meshRoot.rotation.y += dx * 0.01;
|
|
||||||
meshRoot.rotation.x += dy * 0.01;
|
|
||||||
});
|
|
||||||
renderer.domElement.addEventListener('wheel', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
camera.position.multiplyScalar(e.deltaY > 0 ? 1.06 : 0.94);
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
function animate() {
|
|
||||||
rafId = requestAnimationFrame(animate);
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispose() {
|
|
||||||
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
||||||
if (renderer) {
|
|
||||||
if (meshRoot) disposeObject(meshRoot);
|
|
||||||
renderer.dispose();
|
|
||||||
if (renderer.domElement && renderer.domElement.parentNode) {
|
|
||||||
renderer.domElement.parentNode.removeChild(renderer.domElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderer = null;
|
|
||||||
scene = null;
|
|
||||||
camera = null;
|
|
||||||
meshRoot = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.PacksModelViewer = { render: render, dispose: dispose };
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// packs_preview.js — opens a model in the vendored Blockbench app inside a
|
||||||
|
// full-screen overlay. The geo JSON is loaded via the gated pack_asset
|
||||||
|
// endpoint and the texture images are resolved from afw_bone_textures (or the
|
||||||
|
// vanilla entity skin fallback) and handed to Blockbench; the bundled
|
||||||
|
// GeckoLib + Multi Actor Animator plugins handle applying them.
|
||||||
|
(function () {
|
||||||
|
let overlay = null;
|
||||||
|
let iframe = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
const BB_URL = '/static/vendor/blockbench/index.html';
|
||||||
|
|
||||||
|
function ensureOverlay() {
|
||||||
|
if (overlay) return overlay;
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.className = 'bb-overlay';
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="bb-overlay-header">' +
|
||||||
|
'<span class="bb-overlay-title" id="bb-overlay-title"></span>' +
|
||||||
|
'<button type="button" class="bb-overlay-close" id="bb-overlay-close" aria-label="Close"><i class="fas fa-times"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="bb-overlay-status" id="bb-overlay-status"></div>';
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelector('#bb-overlay-close').addEventListener('click', close);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
const el = document.querySelector('#bb-overlay-status');
|
||||||
|
if (el) el.textContent = text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.hidden = true;
|
||||||
|
if (iframe) { iframe.remove(); iframe = 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 s of ['.mf', '.fm', '.m', '.f', '.g']) {
|
||||||
|
if (base.endsWith(s)) { base = base.slice(0, -s.length); break; }
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceToMember(resource) {
|
||||||
|
resource = String(resource);
|
||||||
|
if (resource.indexOf(':') !== -1) {
|
||||||
|
const p = resource.split(':');
|
||||||
|
return 'assets/' + p.shift() + '/' + p.join(':');
|
||||||
|
}
|
||||||
|
if (resource.startsWith('assets/')) return resource;
|
||||||
|
return 'assets/' + resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('Could not load ' + url));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageDataUrl(img) {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = img.naturalWidth;
|
||||||
|
c.height = img.naturalHeight;
|
||||||
|
c.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
return c.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGeo(member, baseUrl) {
|
||||||
|
const r = await fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } });
|
||||||
|
if (!r.ok) throw new Error('Could not load the model file');
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVanillaIndex(vanillaBaseUrl) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(assetUrl(vanillaBaseUrl, 'entity_index.json'));
|
||||||
|
return r.ok ? await r.json() : {};
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the textures to hand to Blockbench: the pack's <entity>_features
|
||||||
|
// overlay(s) plus the vanilla entity skin (a random fur/colour variant when
|
||||||
|
// the mob has several). Blockbench + the bundled plugins UV-wrap and apply
|
||||||
|
// them, so we don't worry about per-bone assignment here.
|
||||||
|
async function resolveTextures(geo, entity, opts, index) {
|
||||||
|
const featureTextures = [];
|
||||||
|
const baseTextures = [];
|
||||||
|
const afw = geo['afw_bone_textures'] || {};
|
||||||
|
|
||||||
|
// 1. Feature overlays — from afw_bone_textures, or the conventional
|
||||||
|
// assets/<ns>/textures/entity/<entity>/<entity>_features.png path.
|
||||||
|
const members = [];
|
||||||
|
let namespace = 'needsofnature';
|
||||||
|
for (const k in afw) {
|
||||||
|
const m = resourceToMember(afw[k]);
|
||||||
|
if (members.indexOf(m) === -1) members.push(m);
|
||||||
|
const colon = String(afw[k]).indexOf(':');
|
||||||
|
if (colon > 0) namespace = String(afw[k]).slice(0, colon);
|
||||||
|
}
|
||||||
|
if (!members.length && entity) {
|
||||||
|
members.push('assets/' + namespace + '/textures/entity/' + entity + '/' + entity + '_features.png');
|
||||||
|
}
|
||||||
|
for (const m of members) {
|
||||||
|
try {
|
||||||
|
const img = await loadImage(assetUrl(opts.baseUrl, m));
|
||||||
|
featureTextures.push({ name: m.split('/').pop(), dataUrl: imageDataUrl(img) });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('packs: skip feature texture', m, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Vanilla base skin — a random variant when several exist, else the
|
||||||
|
// server-matched pack skin, else none.
|
||||||
|
let rel = null;
|
||||||
|
const ent = index && index[entity];
|
||||||
|
if (ent) {
|
||||||
|
const variants = ent.variants || (typeof ent === 'string' ? [ent] : [ent.default]);
|
||||||
|
if (variants && variants.length) rel = variants[Math.floor(Math.random() * variants.length)];
|
||||||
|
}
|
||||||
|
if (rel) {
|
||||||
|
try {
|
||||||
|
const img = await loadImage(assetUrl(opts.vanillaBaseUrl, 'entity/' + rel));
|
||||||
|
baseTextures.push({ name: rel.split('/').pop(), dataUrl: imageDataUrl(img) });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('packs: skip vanilla skin', e);
|
||||||
|
}
|
||||||
|
} else if (opts.defaultTexture) {
|
||||||
|
try {
|
||||||
|
const img = await loadImage(assetUrl(opts.baseUrl, opts.defaultTexture));
|
||||||
|
baseTextures.push({ name: opts.defaultTexture.split('/').pop(), dataUrl: imageDataUrl(img) });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('packs: skip default texture', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The vanilla/base texture must always be loaded first, then features.
|
||||||
|
// Wide player models additionally always load L1Z0's custom skins
|
||||||
|
// alongside the default Steve skin (tracked in static/player_skins/).
|
||||||
|
if (entity === 'player') {
|
||||||
|
for (const name of ['destroyed_skin.png', 'russian_goat.png']) {
|
||||||
|
try {
|
||||||
|
const img = await loadImage('/static/player_skins/' + name);
|
||||||
|
baseTextures.push({ name: name, dataUrl: imageDataUrl(img) });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('packs: skip player skin', name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseTextures.concat(featureTextures);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(btn) {
|
||||||
|
const member = btn.dataset.member;
|
||||||
|
const name = btn.dataset.name || member;
|
||||||
|
const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null };
|
||||||
|
if (!member || !opts.baseUrl || busy) return;
|
||||||
|
busy = true;
|
||||||
|
|
||||||
|
const overlayEl = ensureOverlay();
|
||||||
|
overlayEl.hidden = false;
|
||||||
|
document.querySelector('#bb-overlay-title').textContent = name;
|
||||||
|
setStatus('Starting Blockbench…');
|
||||||
|
|
||||||
|
if (iframe) iframe.remove();
|
||||||
|
iframe = document.createElement('iframe');
|
||||||
|
iframe.className = 'bb-frame';
|
||||||
|
iframe.src = BB_URL;
|
||||||
|
overlayEl.appendChild(iframe);
|
||||||
|
|
||||||
|
const ready = await new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = (ok) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
window.removeEventListener('message', onMsg);
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(ok);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => finish(false), 45000);
|
||||||
|
const onMsg = (e) => {
|
||||||
|
if (e.origin !== window.location.origin) return;
|
||||||
|
if (e.data && e.data.type === 'packs-bb-ready') finish(true);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
setStatus('Blockbench failed to start.');
|
||||||
|
busy = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Loading model…');
|
||||||
|
try {
|
||||||
|
const [geo, index] = await Promise.all([
|
||||||
|
fetchGeo(member, opts.baseUrl),
|
||||||
|
fetchVanillaIndex(opts.vanillaBaseUrl),
|
||||||
|
]);
|
||||||
|
const entity = entityNameFromMember(member);
|
||||||
|
const textures = await resolveTextures(geo, entity, opts, index);
|
||||||
|
iframe.contentWindow.postMessage({
|
||||||
|
type: 'packs-open-model',
|
||||||
|
geo: geo,
|
||||||
|
name: name,
|
||||||
|
textures: textures,
|
||||||
|
}, window.location.origin);
|
||||||
|
setStatus('Applying textures…');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Could not load model: ' + e.message);
|
||||||
|
}
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (e) => {
|
||||||
|
if (e.origin !== window.location.origin) return;
|
||||||
|
if (!e.data) return;
|
||||||
|
if (e.data.type === 'packs-model-open') {
|
||||||
|
setStatus('Ready — drag to orbit, scroll to zoom.');
|
||||||
|
} else if (e.data.type === 'packs-model-error') {
|
||||||
|
setStatus('Model error: ' + (e.data.error || ''));
|
||||||
|
} else if (e.data.type === 'packs-plugin-error') {
|
||||||
|
setStatus('Plugin error: ' + (e.data.error || ''));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.PacksPreview = { open: open, close: close };
|
||||||
|
})();
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
{
|
|
||||||
"afw_bone_textures": {
|
|
||||||
"cylinder": "needsofnature:textures/entity/wolf/wolf_features.png",
|
|
||||||
"bulb": "needsofnature:textures/entity/wolf/wolf_features.png"
|
|
||||||
},
|
|
||||||
"format_version": "1.12.0",
|
|
||||||
"minecraft:geometry": [
|
|
||||||
{
|
|
||||||
"description": {
|
|
||||||
"identifier": "geometry.unknown",
|
|
||||||
"texture_width": 64,
|
|
||||||
"texture_height": 32,
|
|
||||||
"visible_bounds_width": 3,
|
|
||||||
"visible_bounds_height": 2.5,
|
|
||||||
"visible_bounds_offset": [0, 0.75, 0]
|
|
||||||
},
|
|
||||||
"bones": [
|
|
||||||
{
|
|
||||||
"name": "root",
|
|
||||||
"pivot": [0, 0, 0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "wolf",
|
|
||||||
"parent": "root",
|
|
||||||
"pivot": [-1, 10.5, -7]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "frontbody",
|
|
||||||
"parent": "wolf",
|
|
||||||
"pivot": [-1, 10.5, -7]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "head",
|
|
||||||
"parent": "frontbody",
|
|
||||||
"pivot": [-1, 10.5, -7],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-4, 7.5, -9], "size": [6, 6, 4], "uv": [0, 0]},
|
|
||||||
{"origin": [-4, 13.5, -7], "size": [2, 2, 1], "uv": [16, 14]},
|
|
||||||
{"origin": [0, 13.5, -7], "size": [2, 2, 1], "uv": [16, 14]},
|
|
||||||
{"origin": [-2.5, 7.51563, -12], "size": [3, 3, 4], "uv": [0, 10]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mane",
|
|
||||||
"parent": "frontbody",
|
|
||||||
"pivot": [-1, 10, 2],
|
|
||||||
"rotation": [90, 0, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-5, 12, -1], "size": [8, 6, 7], "uv": [21, 0]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leg3",
|
|
||||||
"parent": "frontbody",
|
|
||||||
"pivot": [-2.5, 8, -4],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-3.5, 0, -5], "size": [2, 8, 2], "uv": [0, 18]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leg4",
|
|
||||||
"parent": "frontbody",
|
|
||||||
"pivot": [0.5, 8, -4],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-0.5, 0, -5], "size": [2, 8, 2], "uv": [0, 18]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "body",
|
|
||||||
"parent": "frontbody",
|
|
||||||
"pivot": [0, 10, 2],
|
|
||||||
"rotation": [90, 0, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-4, 3, -1], "size": [6, 9, 6], "uv": [18, 14]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "d",
|
|
||||||
"parent": "body",
|
|
||||||
"pivot": [-1, 5, -0.2],
|
|
||||||
"rotation": [30.36119, -40.78947, -20.94102],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-2, 4, -1.2], "size": [2, 3, 2], "uv": [20, 22]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "cylinder",
|
|
||||||
"parent": "d",
|
|
||||||
"pivot": [-1.48996, 6.28701, -0.48996],
|
|
||||||
"cubes": [
|
|
||||||
{
|
|
||||||
"origin": [-1.98996, 6.28701, -1.18996],
|
|
||||||
"size": [1, 5, 1],
|
|
||||||
"inflate": -0.1,
|
|
||||||
"uv": {
|
|
||||||
"north": {"uv": [52, 9], "uv_size": [3, 15]},
|
|
||||||
"east": {"uv": [49, 9], "uv_size": [3, 15]},
|
|
||||||
"south": {"uv": [58, 9], "uv_size": [3, 15]},
|
|
||||||
"west": {"uv": [55, 9], "uv_size": [3, 15]},
|
|
||||||
"up": {"uv": [52, 6], "uv_size": [3, 3]},
|
|
||||||
"down": {"uv": [55, 9], "uv_size": [3, -3]}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"origin": [-2.08996, 10.68701, -1.28996],
|
|
||||||
"size": [1, 1, 1],
|
|
||||||
"inflate": -0.3,
|
|
||||||
"uv": {
|
|
||||||
"north": {"uv": [54, 4], "uv_size": [2, 2]},
|
|
||||||
"east": {"uv": [52, 4], "uv_size": [2, 2]},
|
|
||||||
"south": {"uv": [58, 4], "uv_size": [2, 2]},
|
|
||||||
"west": {"uv": [56, 4], "uv_size": [2, 2]},
|
|
||||||
"up": {"uv": [54, 2], "uv_size": [2, 2]},
|
|
||||||
"down": {"uv": [56, 4], "uv_size": [2, -2]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "bulb",
|
|
||||||
"parent": "cylinder",
|
|
||||||
"pivot": [-1.51197, 8.36355, -0.68726],
|
|
||||||
"rotation": [0, -47.5, 0],
|
|
||||||
"cubes": [
|
|
||||||
{
|
|
||||||
"origin": [-2.26197, 7.86355, -1.73726],
|
|
||||||
"size": [1.25, 1, 2.1],
|
|
||||||
"uv": {
|
|
||||||
"north": {"uv": [52, 28], "uv_size": [3, 2]},
|
|
||||||
"east": {"uv": [48, 28], "uv_size": [4, 2]},
|
|
||||||
"south": {"uv": [59, 28], "uv_size": [3, 2]},
|
|
||||||
"west": {"uv": [55, 28], "uv_size": [4, 2]},
|
|
||||||
"up": {"uv": [52, 24], "uv_size": [3, 4]},
|
|
||||||
"down": {"uv": [55, 28], "uv_size": [3, -4]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "backbody",
|
|
||||||
"parent": "wolf",
|
|
||||||
"pivot": [0, 10, 2]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leg1",
|
|
||||||
"parent": "backbody",
|
|
||||||
"pivot": [-2.5, 8, 7],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-3.5, 0, 6], "size": [2, 8, 2], "uv": [0, 18]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leg2",
|
|
||||||
"parent": "backbody",
|
|
||||||
"pivot": [0.5, 8, 7],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-0.5, 0, 6], "size": [2, 8, 2], "uv": [0, 18]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "tail",
|
|
||||||
"parent": "backbody",
|
|
||||||
"pivot": [-1, 12, 8],
|
|
||||||
"rotation": [55, 0, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-2, 4, 7], "size": [2, 8, 2], "uv": [9, 18]}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
{
|
|
||||||
"format_version": "1.12.0",
|
|
||||||
"afw_bone_textures": {
|
|
||||||
"body": "needsofnature:textures/entity/zombie/zombie.png",
|
|
||||||
"leftleg": "needsofnature:textures/entity/zombie/zombie.png",
|
|
||||||
"rightleg": "needsofnature:textures/entity/zombie/zombie.png",
|
|
||||||
"d": "needsofnature:textures/entity/zombie/zombie.png"
|
|
||||||
},
|
|
||||||
"minecraft:geometry": [
|
|
||||||
{
|
|
||||||
"description": {
|
|
||||||
"identifier": "geometry.unknown",
|
|
||||||
"texture_width": 64,
|
|
||||||
"texture_height": 64,
|
|
||||||
"visible_bounds_width": 2,
|
|
||||||
"visible_bounds_height": 3.5,
|
|
||||||
"visible_bounds_offset": [0, 1.25, 0]
|
|
||||||
},
|
|
||||||
"bones": [
|
|
||||||
{
|
|
||||||
"name": "root",
|
|
||||||
"pivot": [0, 0, 0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "zombie",
|
|
||||||
"parent": "root",
|
|
||||||
"pivot": [0, 0, 0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "waist",
|
|
||||||
"parent": "zombie",
|
|
||||||
"pivot": [0, 12, 0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "head",
|
|
||||||
"parent": "waist",
|
|
||||||
"pivot": [0, 24, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-4, 24, -4], "size": [8, 8, 8], "uv": [0, 0]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "headwear",
|
|
||||||
"parent": "waist",
|
|
||||||
"pivot": [0, 24, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-4, 24, -4], "size": [8, 8, 8], "inflate": 0.5, "uv": [32, 0]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "body",
|
|
||||||
"parent": "waist",
|
|
||||||
"pivot": [0, 24, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-4, 12, -2], "size": [8, 12, 4], "uv": [16, 16]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "d",
|
|
||||||
"parent": "body",
|
|
||||||
"pivot": [0, 12, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-1, 10.75, -6], "size": [2, 2, 5], "inflate": -0.3, "uv": [17, 8]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leftarm",
|
|
||||||
"parent": "waist",
|
|
||||||
"pivot": [5, 22, 0],
|
|
||||||
"mirror": true,
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [4, 12, -2], "size": [4, 12, 4], "uv": [40, 16]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "propleft",
|
|
||||||
"parent": "leftarm",
|
|
||||||
"pivot": [6, 12, 0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rightarm",
|
|
||||||
"parent": "waist",
|
|
||||||
"pivot": [-5, 22, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-8, 12, -2], "size": [4, 12, 4], "uv": [40, 16]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "propright",
|
|
||||||
"parent": "rightarm",
|
|
||||||
"pivot": [-6, 12, 0]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "leftleg",
|
|
||||||
"parent": "zombie",
|
|
||||||
"pivot": [1.9, 12, 0],
|
|
||||||
"mirror": true,
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [0, 0, -2], "size": [4, 12, 4], "uv": [0, 16]}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rightleg",
|
|
||||||
"parent": "zombie",
|
|
||||||
"pivot": [-1.9, 12, 0],
|
|
||||||
"cubes": [
|
|
||||||
{"origin": [-4, 0, -2], "size": [4, 12, 4], "uv": [0, 16]}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
// Unit tests for geo_builder.js — run with: node static/js/tests/geo_builder.test.js
|
|
||||||
// No framework: asserts, prints a summary, exits non-zero on failure.
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const GeoBuilder = require(path.join(__dirname, '..', 'geo_builder.js'));
|
|
||||||
|
|
||||||
const zombie = require(path.join(__dirname, 'fixtures', 'zombie.geo.json'));
|
|
||||||
|
|
||||||
let failures = 0;
|
|
||||||
let checks = 0;
|
|
||||||
|
|
||||||
function approx(a, b, tol = 1e-6) {
|
|
||||||
return Math.abs(a - b) <= tol;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertVec(actual, expected, label, tol = 1e-6) {
|
|
||||||
checks++;
|
|
||||||
const ok = approx(actual[0], expected[0], tol) && approx(actual[1], expected[1], tol) && approx(actual[2], expected[2], tol);
|
|
||||||
if (!ok) {
|
|
||||||
failures++;
|
|
||||||
console.error(`FAIL ${label}: got [${actual.map(v => v.toFixed(4)).join(', ')}] expected [${expected.join(', ')}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(cond, label) {
|
|
||||||
checks++;
|
|
||||||
if (!cond) { failures++; console.error(`FAIL ${label}`); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function worldCenter(matrix) {
|
|
||||||
return GeoBuilder.transformPoint(matrix, [0, 0, 0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 1. Real zombie model (nested hierarchy, identity rotations) ----------
|
|
||||||
const built = GeoBuilder.build(zombie);
|
|
||||||
const tw = built.texture_width, th = built.texture_height;
|
|
||||||
|
|
||||||
assert(built.cubes.length === 8, `zombie cube count (got ${built.cubes.length})`);
|
|
||||||
|
|
||||||
const centers = built.cubes.map(c => ({ c, center: worldCenter(c.matrix) }));
|
|
||||||
|
|
||||||
function cubeNear(center) {
|
|
||||||
return centers.filter(({ center: c }) =>
|
|
||||||
approx(c[0], center[0], 1e-6) && approx(c[1], center[1], 1e-6) && approx(c[2], center[2], 1e-6)
|
|
||||||
).map(x => x.c);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(cubeNear([0, 28, 0]).length >= 2, 'head + headwear at (0,28,0)'); // head & headwear
|
|
||||||
assert(cubeNear([0, 18, 0]).length === 1, 'body at (0,18,0)');
|
|
||||||
assert(cubeNear([6, 18, 0]).length === 1, 'leftarm at (6,18,0)');
|
|
||||||
assert(cubeNear([-6, 18, 0]).length === 1, 'rightarm at (-6,18,0)');
|
|
||||||
assert(cubeNear([2, 6, 0]).length === 1, 'leftleg at (2,6,0)');
|
|
||||||
assert(cubeNear([-2, 6, 0]).length === 1, 'rightleg at (-2,6,0)');
|
|
||||||
assert(cubeNear([0, 11.75, -3.5]).length === 1, 'd at (0,11.75,-3.5)');
|
|
||||||
|
|
||||||
// Head = the (0,28,0) cube with uv [0,0]; headwear has uv [32,0].
|
|
||||||
const headCube = cubeNear([0, 28, 0]).find(c => c.uv[0] === 0 && c.uv[1] === 0);
|
|
||||||
assert(!!headCube, 'head cube identified');
|
|
||||||
if (headCube) {
|
|
||||||
// Head north face (last face) must sample the vanilla (8..16, 8..16) region.
|
|
||||||
const geo = GeoBuilder.cubeGeometry(headCube.size, headCube.uv, tw, th);
|
|
||||||
const northStart = 5 * 8; // face order: east,west,top,bottom,south,north
|
|
||||||
let minU = 2, maxU = -1, minV = 2, maxV = -1;
|
|
||||||
for (let i = 0; i < 8; i += 2) {
|
|
||||||
minU = Math.min(minU, geo.uvs[northStart + i]);
|
|
||||||
maxU = Math.max(maxU, geo.uvs[northStart + i]);
|
|
||||||
minV = Math.min(minV, geo.uvs[northStart + i + 1]);
|
|
||||||
maxV = Math.max(maxV, geo.uvs[northStart + i + 1]);
|
|
||||||
}
|
|
||||||
assert(approx(minU, 8 / 64) && approx(maxU, 16 / 64), `head north U in (8..16)/64 (got ${minU.toFixed(3)}..${maxU.toFixed(3)})`);
|
|
||||||
assert(approx(minV, 1 - 16 / 64) && approx(maxV, 1 - 8 / 64), `head north V in [0.75,0.875] (got ${minV.toFixed(3)}..${maxV.toFixed(3)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All UVs within [0,1].
|
|
||||||
let allInRange = true;
|
|
||||||
for (const { c } of centers) {
|
|
||||||
const g = GeoBuilder.cubeGeometry(c.size, c.uv, tw, th);
|
|
||||||
for (const u of g.uvs) if (u < 0 || u > 1) { allInRange = false; break; }
|
|
||||||
}
|
|
||||||
assert(allInRange, 'all UVs within [0,1]');
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
const rotated = {
|
|
||||||
'minecraft:geometry': [{
|
|
||||||
description: { identifier: 'geometry.t', texture_width: 64, texture_height: 64 },
|
|
||||||
bones: [{
|
|
||||||
name: 'waist', pivot: [0, 10, 0], rotation: [0, 0, 90],
|
|
||||||
cubes: [{ origin: [0, 10, 0], size: [2, 2, 2], uv: [0, 0] }],
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
const rotBuilt = GeoBuilder.build(rotated);
|
|
||||||
assert(rotBuilt.cubes.length === 1, 'rotated model has one cube');
|
|
||||||
// cube center = origin + size/2 = (1,11,1); Rz(-90) around pivot (0,10,0):
|
|
||||||
// rel (1,1,1) -> (1,-1,1) -> +pivot = (1,9,1)
|
|
||||||
const rotCenter = worldCenter(rotBuilt.cubes[0].matrix);
|
|
||||||
assertVec(rotCenter, [1, 9, 1], 'rotated cube center around pivot (expect (1,9,1))');
|
|
||||||
|
|
||||||
// ---------- 3. Nested pivot with rotation ----------
|
|
||||||
const nested = {
|
|
||||||
'minecraft:geometry': [{
|
|
||||||
description: { identifier: 'geometry.n', texture_width: 64, texture_height: 64 },
|
|
||||||
bones: [
|
|
||||||
{ name: 'root', pivot: [0, 0, 0] },
|
|
||||||
{ name: 'waist', pivot: [0, 12, 0], parent: 'root', rotation: [0, 0, 0] },
|
|
||||||
{ name: 'head', pivot: [0, 24, 0], parent: 'waist',
|
|
||||||
cubes: [{ origin: [-4, 24, -4], size: [8, 8, 8], uv: [0, 0] }] },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
const nestedBuilt = GeoBuilder.build(nested);
|
|
||||||
assertVec(worldCenter(nestedBuilt.cubes[0].matrix), [0, 28, 0], 'nested pivot head center (expect (0,28,0))');
|
|
||||||
|
|
||||||
// ---------- 4. Real wolf model: rotated bones must produce a quadruped layout ----------
|
|
||||||
const wolf = require(path.join(__dirname, 'fixtures', 'wolf.geo.json'));
|
|
||||||
const wolfBuilt = GeoBuilder.build(wolf);
|
|
||||||
const wolfCenters = wolfBuilt.cubes.map(c => ({ c, center: worldCenter(c.matrix) }));
|
|
||||||
|
|
||||||
const wBody = wolfCenters.find(({ c }) => c.size[0] === 8 && c.size[1] === 6 && c.size[2] === 7);
|
|
||||||
assert(!!wBody, 'wolf body cube found');
|
|
||||||
if (wBody) {
|
|
||||||
// Body high (y~10.5) and in the front half (z<2). Under the old rotation
|
|
||||||
// sign the body landed at the back (z=7) — this catches the regression.
|
|
||||||
assertVec(wBody.center, [-1, 10.5, -3], 'wolf body center (expect (-1,10.5,-3))');
|
|
||||||
}
|
|
||||||
|
|
||||||
const wHead = wolfCenters.find(({ c }) => c.size[0] === 6 && c.size[1] === 6 && c.size[2] === 4);
|
|
||||||
assert(!!wHead, 'wolf head cube found');
|
|
||||||
if (wHead) {
|
|
||||||
assertVec(wHead.center, [-1, 10.5, -7], 'wolf head center (expect (-1,10.5,-7))');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4 legs (size 2x8x2 cubes whose y is near the ground).
|
|
||||||
const wLegs = wolfCenters.filter(({ c, center }) => c.size[0] === 2 && c.size[1] === 8 && c.size[2] === 2 && center[1] < 6);
|
|
||||||
assert(wLegs.length === 4, `wolf has 4 legs near the ground (got ${wLegs.length})`);
|
|
||||||
wLegs.forEach(({ center }) => assert(approx(center[1], 4.0, 0.2), `leg at ground level y=${center[1].toFixed(1)}`));
|
|
||||||
|
|
||||||
// Tail: the cube with the largest |z|, behind the body.
|
|
||||||
let wTail = wolfCenters[0];
|
|
||||||
for (const w of wolfCenters) {
|
|
||||||
if (Math.abs(w.center[2]) > Math.abs(wTail.center[2])) wTail = w;
|
|
||||||
}
|
|
||||||
assert(wTail.center[2] > 8, `wolf tail behind body (z=${wTail.center[2].toFixed(1)})`);
|
|
||||||
assert(wBody && wTail.center[2] > wBody.center[2], 'tail z > body z');
|
|
||||||
|
|
||||||
// ---------- 5. Real polar bear: cube-level pivot + rotation ----------
|
|
||||||
// The polar bear's rear torso cube has its own pivot/rotation; rotating around
|
|
||||||
// the cube origin (not its pivot) sent it flying to z≈48. Regression test.
|
|
||||||
const polar = require(path.join(__dirname, 'fixtures', 'polar_bear.geo.json'));
|
|
||||||
const polarBuilt = GeoBuilder.build(polar);
|
|
||||||
const polarCubes = polarBuilt.cubes.map(c => ({ c, center: worldCenter(c.matrix) }));
|
|
||||||
const pRear = polarCubes.find(({ c }) => c.size[0] === 18.2 && c.size[1] === 18.2 && c.size[2] === 14.3);
|
|
||||||
assert(!!pRear, 'polar bear rear torso cube found');
|
|
||||||
if (pRear) {
|
|
||||||
assertVec(pRear.center, [0, 17.55, 7.8], 'polar bear rear torso center (expect (0,17.55,7.8))');
|
|
||||||
}
|
|
||||||
const pFront = polarCubes.find(({ c }) => c.size[0] === 15.6 && c.size[1] === 15.6 && c.size[2] === 13);
|
|
||||||
if (pFront) {
|
|
||||||
// Rear torso sits behind and at the same height as the front torso.
|
|
||||||
assert(pRear.center[2] > pFront.center[2], 'rear torso behind front torso');
|
|
||||||
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)`);
|
|
||||||
} else {
|
|
||||||
console.error(`${failures}/${checks} checks FAILED`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 944 B |
|
After Width: | Height: | Size: 809 B |
|
After Width: | Height: | Size: 472 B |
|
After Width: | Height: | Size: 485 B |
|
After Width: | Height: | Size: 457 B |
|
After Width: | Height: | Size: 453 B |
|
After Width: | Height: | Size: 475 B |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 949 B |
|
After Width: | Height: | Size: 266 B |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 836 B |
|
After Width: | Height: | Size: 664 B |
|
After Width: | Height: | Size: 646 B |
|
After Width: | Height: | Size: 714 B |
|
After Width: | Height: | Size: 701 B |
|
After Width: | Height: | Size: 246 B |
|
After Width: | Height: | Size: 483 B |
|
After Width: | Height: | Size: 423 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 214 B |
|
After Width: | Height: | Size: 537 B |
|
After Width: | Height: | Size: 583 B |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 577 B |
|
After Width: | Height: | Size: 361 B |
|
After Width: | Height: | Size: 128 B |
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 437 B |
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 318 B |
|
After Width: | Height: | Size: 320 B |
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 294 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 475 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 449 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 661 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 359 B |
|
After Width: | Height: | Size: 312 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 329 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 760 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 778 B |
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 777 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 481 B |
|
After Width: | Height: | Size: 480 B |
|
After Width: | Height: | Size: 80 B |
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 370 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 729 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |