Merge branch 'blockbench_renderer' into 'master'

Blockbench renderer

See merge request JakeBreath/NoN-Packs-Sit!1
This commit is contained in:
JakeBreath
2026-08-04 19:06:58 -05:00
685 changed files with 49456 additions and 1140 deletions
+1
View File
@@ -5,5 +5,6 @@ Packs_DB
.migrations_done
nonpacks/staticfiles/
nonpacks/media/
nonpacks/static/vanilla/entity_index.json
*.log
AGENTS/
@@ -4,7 +4,7 @@ from library.vanilla_textures import refresh_vanilla_index
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):
index = refresh_vanilla_index()
-1
View File
@@ -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>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
path('packs/<slug:slug>/versions/<int:version_id>/asset/<path:asset_path>', views.pack_asset, name='pack_asset'),
path('vanilla/<path:asset_path>', views.vanilla_asset, name='vanilla_asset'),
path('packs/<slug:slug>/guide/<int:version_id>/<uuid:file_uuid>/', views.guide_doc, name='guide_doc'),
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
path('packs/<slug:slug>/gallery/<int:asset_id>/thumb/', views.asset_thumbnail, name='asset_thumbnail'),
+44 -14
View File
@@ -1,9 +1,10 @@
"""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.
Scans ``static/vanilla/entity/`` (extracted from a Minecraft client jar,
tracked in git) and writes ``static/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
@@ -13,6 +14,11 @@ from django.conf import settings
ENTITY_ROOT = 'entity'
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.
ALIASES = {
'polar_bear': 'bear/polarbear.png',
@@ -50,12 +56,32 @@ _LAYER_KEYWORDS = (
'_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):
"""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)
root = os.path.join(vanilla_dir(), ENTITY_ROOT)
top = os.path.join(root, entity + '.png')
if os.path.isfile(top):
return entity, [entity + '.png']
@@ -77,8 +103,8 @@ def _base_candidates(entity):
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)
"""Rebuild the entity → {default, variants} skin map from the vanilla set."""
root = os.path.join(vanilla_dir(), ENTITY_ROOT)
if not os.path.isdir(root):
raise FileNotFoundError(f'{root} missing — extract vanilla entity textures first')
@@ -87,21 +113,25 @@ def refresh_vanilla_index():
rel = os.path.join(root, entry)
if entry.endswith('.png'):
entity = entry[:-4]
if not index.get(entity):
index[entity] = entry
index.setdefault(entity, {'default': entry, 'variants': [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]
if candidates and entity not in index:
index[entity] = {'default': candidates[0], 'variants': [candidates[0]]}
for entity, rel in ALIASES.items():
if not index.get(entity):
index[entity] = rel
if entity not in index:
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:
json.dump(index, f, indent=1, sort_keys=True)
return index
-13
View File
@@ -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):
"""Render one markdown guide document server-side (used by the switcher)."""
version = get_object_or_404(
+61
View File
@@ -3268,3 +3268,64 @@ a.deletelink {
color: var(--md-sys-color-on-error-container, #7f1d1d);
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;
}
-291
View File
@@ -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,
};
});
-254
View File
@@ -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 };
})();
+240
View File
@@ -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 };
})();
File diff suppressed because one or more lines are too long
-172
View File
@@ -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]}
]
}
]
}
]
}
-113
View File
@@ -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);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 777 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Some files were not shown because too many files have changed in this diff Show More