backup befor new branch to test Blockbenh instead of regex renderer

This commit is contained in:
2026-08-04 16:04:30 -05:00
parent f59e9a165c
commit 8c17c273fe
11 changed files with 376 additions and 19 deletions
+37 -2
View File
@@ -83,11 +83,29 @@
};
}
// Blockbench per-face UV: {north:{uv:[u,v], uv_size:[w,h]}, ...} with
// up/down for top/bottom. Negative uv_size mirrors that face (180° flip).
function perFaceRects(uv) {
var map = { top: 'up', bottom: 'down', north: 'north', south: 'south', east: 'east', west: 'west' };
var rects = {};
for (var face in map) {
var p = uv[map[face]];
if (!p || !Array.isArray(p.uv)) {
rects[face] = [0, 0, 0, 0];
continue;
}
var w = (p.uv_size && p.uv_size[0]) || 0;
var h = (p.uv_size && p.uv_size[1]) || 0;
rects[face] = [p.uv[0], p.uv[1], w, h];
}
return rects;
}
// Build one cube as a BufferGeometry-compatible mesh:
// 24 positions (6 faces x 4 corners), 24 UVs, 36 indices, outward winding.
function cubeGeometry(size, uv, tw, th) {
var hx = size[0] / 2, hy = size[1] / 2, hz = size[2] / 2;
var rects = boxUVRects(uv, size);
var rects = Array.isArray(uv) ? boxUVRects(uv, size) : perFaceRects(uv);
var order = ['east', 'west', 'top', 'bottom', 'south', 'north'];
var outward = {
east: [1, 0, 0], west: [-1, 0, 0],
@@ -167,13 +185,28 @@
}
// ---------- model build ----------
// Resource locations like "needsofnature:textures/entity/x.png" resolve to
// the zip member "assets/needsofnature/textures/entity/x.png".
function resourceToMember(resource) {
resource = String(resource);
if (resource.indexOf(':') !== -1) {
var p = resource.split(':');
return 'assets/' + p.shift() + '/' + p.join(':');
}
if (resource.indexOf('assets/') === 0) return resource;
return 'assets/' + resource;
}
// Returns { cubes: [{matrix, size, uv, texture}], texture_width, texture_height }
function build(geo) {
var geometry = ((geo['minecraft:geometry'] || [])[0]) || null;
if (!geometry) return { cubes: [], texture_width: 64, texture_height: 64 };
var tw = (geometry.description && geometry.description.texture_width) || 64;
var th = (geometry.description && geometry.description.texture_height) || 64;
var textures = geo['afw_bone_textures'] || {};
var textures = {};
for (var k in (geo['afw_bone_textures'] || {})) {
textures[k] = resourceToMember(geo['afw_bone_textures'][k]);
}
var defaultTex = null;
for (var k in textures) { defaultTex = textures[k]; break; }
@@ -247,6 +280,8 @@
build: build,
cubeGeometry: cubeGeometry,
boxUVRects: boxUVRects,
perFaceRects: perFaceRects,
resourceToMember: resourceToMember,
transformPoint: transformPoint,
identity: identity,
translation: translation,
+119 -13
View File
@@ -1,6 +1,12 @@
// GeoJSON model viewer for the Models/Textures tab. Uses the shared GeoBuilder
// geometry math (bone transforms + box-UV) and renders world-space cubes with
// Three.js. Textures are served through the gated pack_asset endpoint.
// geometry math (bone transforms + box/per-face UV) and renders world-space
// cubes with Three.js. Textures are served through the gated pack_asset and
// vanilla_asset endpoints, resolved per cube:
// 1. afw_bone_textures (pack member), composited over the vanilla entity
// skin when one exists (NoN `*_features` overlays sit on the vanilla base)
// 2. the model's default pack skin (server-matched)
// 3. the bundled vanilla entity skin
// 4. gray
(function () {
let renderer = null;
let scene = null;
@@ -9,32 +15,117 @@
let rafId = null;
const textureLoader = new THREE.TextureLoader();
const materialCache = {};
let vanillaIndex = null;
let vanillaIndexPromise = null;
function assetUrl(baseUrl, member) {
return baseUrl.replace('ASSET', member);
}
function entityNameFromMember(member) {
let base = member.slice(member.lastIndexOf('/') + 1);
if (base.endsWith('.geo.json')) base = base.slice(0, -'.geo.json'.length);
for (const suffix of ['.mf', '.fm', '.m', '.f', '.g']) {
if (base.endsWith(suffix)) { base = base.slice(0, -suffix.length); break; }
}
return base;
}
function buildGeometry(size, uv, tw, th) {
const g = GeoBuilder.cubeGeometry(size, uv, tw, th);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(g.positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(g.uvs, 2));
geometry.setIndex(g.indices);
geometry.computeVertexNormals();
return geometry;
}
function materialFor(member, baseUrl) {
if (materialCache[member || '__gray__']) return materialCache[member || '__gray__'];
function fetchVanillaIndex(opts) {
if (vanillaIndexPromise) return vanillaIndexPromise;
if (!opts.vanillaBaseUrl) {
vanillaIndexPromise = Promise.resolve({});
} else {
vanillaIndexPromise = fetch(assetUrl(opts.vanillaBaseUrl, 'entity_index.json'))
.then(r => r.ok ? r.json() : {})
.catch(() => ({}));
}
return vanillaIndexPromise;
}
function vanillaSkinUrl(opts, index, entity) {
const rel = (index && index[entity]) || null;
return rel ? assetUrl(opts.vanillaBaseUrl, 'entity/' + rel) : null;
}
// desc: { url, vanillaUrl } — url is the pack/overlay member, vanillaUrl the
// base skin to composite it over (when available).
function resolveTexture(cube, entity, opts, index) {
if (cube.texture) {
return {
url: assetUrl(opts.baseUrl, cube.texture),
vanillaUrl: vanillaSkinUrl(opts, index, entity),
};
}
if (opts.defaultTexture) {
return { url: assetUrl(opts.baseUrl, opts.defaultTexture), vanillaUrl: null };
}
const rel = (index && index[entity]) || null;
if (rel) {
return { url: null, vanillaUrl: vanillaSkinUrl(opts, index, entity) };
}
return null;
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('img'));
img.src = url;
});
}
async function prepareMaterial(desc, tw, th) {
const key = (desc.url || '') + '|' + (desc.vanillaUrl || '');
if (materialCache[key]) return materialCache[key];
let texture = null;
if (desc.url && desc.vanillaUrl) {
try {
const [overlay, base] = await Promise.all([loadImage(desc.url), loadImage(desc.vanillaUrl)]);
const canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
const ctx = canvas.getContext('2d');
ctx.drawImage(base, 0, 0, tw, th);
ctx.drawImage(overlay, 0, 0, tw, th);
texture = new THREE.CanvasTexture(canvas);
} catch (e) {
texture = null;
}
}
const singleUrl = texture ? null : (desc.url || desc.vanillaUrl);
if (singleUrl) texture = textureLoader.load(singleUrl);
let material;
if (member) {
const texture = textureLoader.load(assetUrl(baseUrl, member));
if (texture) {
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
material = new THREE.MeshStandardMaterial({ map: texture, roughness: 0.9, metalness: 0.0 });
if (singleUrl) {
textureLoader.load(singleUrl, undefined, undefined, () => {
if (material.map) material.map.dispose();
material.map = null;
material.color.setHex(0x9a9a9a);
material.needsUpdate = true;
});
}
} else {
material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 });
}
materialCache[member || '__gray__'] = material;
materialCache[key] = material;
return material;
}
@@ -49,7 +140,7 @@
for (const key in materialCache) delete materialCache[key];
}
function render(member, container, baseUrl) {
function render(member, container, opts) {
if (renderer) dispose();
const width = container.clientWidth || 480;
const height = container.clientHeight || 480;
@@ -60,7 +151,7 @@
camera.position.set(0, 20, 60);
camera.lookAt(0, 12, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio || 1);
container.appendChild(renderer.domElement);
@@ -76,14 +167,29 @@
meshRoot = new THREE.Group();
scene.add(meshRoot);
fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } })
const entity = entityNameFromMember(member);
fetch(assetUrl(opts.baseUrl, member), { headers: { 'Accept': 'application/json' } })
.then(r => { if (!r.ok) throw new Error('http'); return r.json(); })
.then(geo => {
.then(geo => Promise.all([geo, fetchVanillaIndex(opts)]))
.then(([geo, index]) => {
const built = GeoBuilder.build(geo);
if (!built.cubes.length) throw new Error('no cubes');
for (const cube of built.cubes) {
const tw = built.texture_width, th = built.texture_height;
const jobs = built.cubes.map((cube) => {
const desc = resolveTexture(cube, entity, opts, index);
return desc ? prepareMaterial(desc, tw, th) : Promise.resolve(null);
});
return Promise.all(jobs).then((materials) => ({ built, materials }));
})
.then(({ built, materials }) => {
for (let i = 0; i < built.cubes.length; i++) {
const cube = built.cubes[i];
const geometry = buildGeometry(cube.size, cube.uv, built.texture_width, built.texture_height);
const mesh = new THREE.Mesh(geometry, materialFor(cube.texture, baseUrl));
let material = materials[i];
if (!material) {
material = new THREE.MeshStandardMaterial({ color: 0x9a9a9a, roughness: 0.9 });
}
const mesh = new THREE.Mesh(geometry, material);
mesh.matrix = new THREE.Matrix4().fromArray(cube.matrix);
mesh.matrixAutoUpdate = false;
meshRoot.add(mesh);
+61 -2
View File
@@ -80,8 +80,12 @@ for (const { c } of centers) {
}
assert(allInRange, 'all UVs within [0,1]');
// Unmapped bones default to the pack's main texture.
assert(headCube.texture === 'needsofnature:textures/entity/zombie/zombie.png', 'head uses default texture');
// Unmapped bones default to the pack's main texture (resource location
// converted to the zip member path).
assert(headCube.texture === 'assets/needsofnature/textures/entity/zombie/zombie.png', 'head uses default texture');
assert(GeoBuilder.resourceToMember('needsofnature:textures/entity/zombie/zombie.png') === 'assets/needsofnature/textures/entity/zombie/zombie.png', 'resource location converted to member');
assert(GeoBuilder.resourceToMember('assets/needsofnature/textures/x.png') === 'assets/needsofnature/textures/x.png', 'already-member unchanged');
assert(GeoBuilder.resourceToMember('textures/plain.png') === 'assets/textures/plain.png', 'bare path prefixed');
// ---------- 2. Synthetic rotated bone: Rz(-90°) around pivot (0,10,0) ----------
// Rotations are negated to match the Bedrock/GeckoLib direction.
@@ -166,6 +170,61 @@ if (pFront) {
assert(approx(pRear.center[1], pFront.center[1], 1.5), 'rear + front torso at similar height');
}
// ---------- 6. Per-face UV (Blockbench) cubes produce finite, in-range UVs ----------
// Real per-face cube from the default pack phantom model (down face is flipped
// via negative uv_size).
const perFace = {
'minecraft:geometry': [{
description: { identifier: 'geometry.pf', texture_width: 64, texture_height: 64 },
bones: [{
name: 'd2',
cubes: [{
origin: [0, 0, 0], size: [4, 8, 4],
uv: {
north: { uv: [8, 40], uv_size: [4, 8] },
east: { uv: [0, 40], uv_size: [4, 8] },
south: { uv: [12, 40], uv_size: [4, 8] },
west: { uv: [4, 40], uv_size: [4, 8] },
up: { uv: [4, 36], uv_size: [4, 4] },
down: { uv: [8, 40], uv_size: [4, -4] },
},
}],
}],
}],
};
const pfBuilt = GeoBuilder.build(perFace);
const pfCube = pfBuilt.cubes[0];
assert(pfCube && typeof pfCube.uv === 'object', 'per-face cube passed through build()');
if (pfCube) {
const g = GeoBuilder.cubeGeometry(pfCube.size, pfCube.uv, 64, 64);
assert(g.uvs.length === 48, 'per-face geometry emits 24 UVs (48 floats)');
let allFinite = true, allIn01 = true;
for (const u of g.uvs) {
if (!Number.isFinite(u)) allFinite = false;
if (u < 0 || u > 1) allIn01 = false;
}
assert(allFinite, 'per-face UVs are finite (no NaN)');
assert(allIn01, 'per-face UVs within [0,1]');
// south face = index 4 in face order [east,west,top,bottom,south,north]:
// rect [12,40,4,8] -> U 12/64..16/64, V flipped: 1-(40+8)/64 .. 1-40/64.
const southStart = 4 * 8;
let minU = 2, maxU = -1, minV = 2, maxV = -1;
for (let i = 0; i < 8; i += 2) {
minU = Math.min(minU, g.uvs[southStart + i]);
maxU = Math.max(maxU, g.uvs[southStart + i]);
minV = Math.min(minV, g.uvs[southStart + i + 1]);
maxV = Math.max(maxV, g.uvs[southStart + i + 1]);
}
assert(approx(minU, 12 / 64) && approx(maxU, 16 / 64), `per-face south U in (12..16)/64 (got ${minU.toFixed(3)}..${maxU.toFixed(3)})`);
assert(approx(minV, 1 - 48 / 64) && approx(maxV, 1 - 40 / 64), `per-face south V in [0.25,0.375] (got ${minV.toFixed(3)}..${maxV.toFixed(3)})`);
}
// ---------- 7. Per-face rects: negative uv_size mirrors (down face) ----------
const rects = GeoBuilder.perFaceRects(perFace['minecraft:geometry'][0].bones[0].cubes[0].uv);
assert(rects.bottom[3] === -4, 'negative uv_size preserved for down face');
assert(rects.top[3] === 4 && rects.south[2] === 4, 'positive faces keep size');
assert(Array.isArray(GeoBuilder.boxUVRects([0, 0], [4, 8, 4]).south), 'boxUVRects still array-based');
// ---------- summary ----------
if (failures === 0) {
console.log(`geo_builder tests OK (${checks} checks)`);