Files
last-hearth/tests/fauna-art.test.mjs
T
2026-11-05 09:57:11 -05:00

247 lines
15 KiB
JavaScript
Executable File

// Independent little-endian B3D reader + forward kinematics. No encoder helpers,
// engine mocks or assumptions that a magic header implies a working animation.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { inflateSync } from 'node:zlib';
import { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, FAUNA_MATERIALS,
faunaB3D, faunaTexture, faunaBounds, faunaPortraitSheet } from '../scripts/fauna-art.mjs';
import { png } from '../scripts/assets.mjs';
import { worldArt, animalOBJ } from '../scripts/world-art.mjs';
const names='hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm'.split(' ');
const hash=b=>createHash('sha256').update(b).digest('hex');
const sub=(a,b)=>a.map((v,i)=>v-b[i]);
const dot=(a,b)=>a.reduce((s,n,i)=>s+n*b[i],0);
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const near=(a,b,epsilon=1e-5)=>assert.ok(Math.abs(a-b)<epsilon,`${a} != ${b}`);
function parse(bytes) {
const nodes=[],vertices=[],triangles=[]; let anim,texture,meshCount=0,brushCount=0;
const integer=p=>bytes.readInt32LE(p);
const floats=(p,n)=>Array.from({length:n},(_,i)=>bytes.readFloatLE(p+i*4));
function string(p,end) {
const stop=bytes.indexOf(0,p); assert.ok(stop>=p&&stop<end);
return [bytes.toString('ascii',p,stop),stop+1];
}
function chunks(start,end,parent=null) {
let p=start;
while(p<end) {
assert.ok(p+8<=end);
const tag=bytes.toString('ascii',p,p+4),size=integer(p+4),a=p+8,b=a+size;
assert.ok(size>=0&&b<=end); p=b;
if(tag==='BB3D') { assert.equal(integer(a),1); chunks(a+4,b); }
else if(tag==='TEXS') {
const [name,next]=string(a,b); texture=name;
assert.deepEqual([integer(next),integer(next+4)],[1,2]);
assert.deepEqual(floats(next+8,5),[0,0,1,1,0]); assert.equal(next+28,b);
} else if(tag==='BRUS') {
brushCount++; assert.equal(integer(a),1); const [,next]=string(a+4,b);
assert.deepEqual(floats(next,5),[1,1,1,1,0]);
assert.deepEqual([integer(next+20),integer(next+24),integer(next+28)],[1,0,0]);
assert.equal(next+32,b);
} else if(tag==='NODE') {
const [name,next]=string(a,b), transform=floats(next,10);
const node={name,parent,pos:transform.slice(0,3),weights:[],keys:[]}; nodes.push(node);
assert.deepEqual(transform.slice(3),[1,1,1,1,0,0,0]); chunks(next+40,b,node);
} else if(tag==='MESH') { meshCount++; assert.equal(integer(a),0); chunks(a+4,b,parent); }
else if(tag==='VRTS') {
assert.deepEqual([integer(a),integer(a+4),integer(a+8)],[1,1,2]);
assert.equal((size-12)%32,0);
for(let q=a+12;q<b;q+=32) vertices.push(floats(q,8));
} else if(tag==='TRIS') {
assert.equal(integer(a),0); assert.equal((size-4)%12,0);
for(let q=a+4;q<b;q+=12) triangles.push([integer(q),integer(q+4),integer(q+8)]);
} else if(tag==='BONE') {
assert.ok(parent); assert.equal(size%8,0);
for(let q=a;q<b;q+=8) parent.weights.push([integer(q),bytes.readFloatLE(q+4)]);
} else if(tag==='KEYS') {
assert.ok(parent); assert.equal(integer(a),5); assert.equal((size-4)%32,0);
for(let q=a+4;q<b;q+=32) parent.keys.push({frame:integer(q),pos:floats(q+4,3),rotation:floats(q+16,4)});
} else if(tag==='ANIM') {
assert.ok(!anim); assert.equal(size,12); anim=[integer(a),integer(a+4),bytes.readFloatLE(a+8)];
} else assert.fail(`Unexpected B3D chunk ${tag}`);
}
assert.equal(p,end);
}
chunks(0,bytes.length);
assert.equal(bytes.toString('ascii',0,4),'BB3D'); assert.equal(integer(4)+8,bytes.length);
assert.equal(meshCount,1); assert.equal(brushCount,1);
return {nodes,vertices,triangles,anim,texture};
}
function multiply([w,x,y,z],[a,b,c,d]) {
return [w*a-x*b-y*c-z*d,w*b+x*a+y*d-z*c,w*c-x*d+y*a+z*b,w*d+x*c-y*b+z*a];
}
function rotate(q,v) { return multiply(multiply(q,[0,...v]),[q[0],-q[1],-q[2],-q[3]]).slice(1); }
function skin(mesh,frame) {
const states=new Map(),output=[];
for(const node of mesh.nodes) {
const p=states.get(node.parent), key=node.keys[frame];
const bind=node.pos.map((v,i)=>v+(p?.bind[i]||0));
const q=p?multiply(p.q,key.rotation):key.rotation;
const pos=p?rotate(p.q,key.pos).map((v,i)=>v+p.pos[i]):key.pos;
states.set(node,{bind,q,pos});
for(const [id,weight] of node.weights) {
const point=rotate(q,sub(mesh.vertices[id].slice(0,3),bind)).map((v,i)=>v+pos[i]);
output[id]=point.map(v=>v*weight);
}
}
return output;
}
test('exact immutable 23-species/5-clip handoff, provenance, fresh deterministic outputs and fail-closed inputs',()=> {
assert.deepEqual(FAUNA_NAMES,names);
assert.deepEqual(FAUNA_ANIMATIONS,{idle:[0,19,24],locomotion:[20,39,24],feed:[40,59,24],flight_swim:[60,79,24],rest:[80,99,24]});
assert.equal(FAUNA_ART_MANIFEST.length,23); assert.equal(FAUNA_MATERIALS.length,16);
for(const entry of FAUNA_ART_MANIFEST) {
assert.ok(Object.isFrozen(entry)&&Object.isFrozen(entry.bounds.min));
assert.equal(entry.mesh,`hearth_fauna_${entry.id}.b3d`); assert.equal(entry.texture,`hearth_fauna_${entry.id}.png`);
assert.deepEqual(entry.bounds,faunaBounds(entry.id)); assert.equal(entry.unitsPerNode,10); assert.equal(entry.visualSize,1);
assert.equal(entry.license,'MIT'); assert.ok(entry.posture.length>15); assert.deepEqual(entry.clips,FAUNA_ANIMATIONS);
assert.deepEqual(faunaB3D(entry.id),faunaB3D(entry.id)); assert.deepEqual(faunaTexture(entry.id),faunaTexture(entry.id));
const altered=faunaTexture(entry.id); altered.data.fill(0); assert.ok(faunaTexture(entry.id).data.some(v=>v));
}
for(const bad of ['../hare','constructor','toString','Hare',null,{},'wolf']) {
for(const fn of [faunaB3D,faunaTexture,faunaBounds]) assert.throws(()=>fn(bad),/Unknown fauna species/);
}
for(const bad of [-1,100,.5,NaN,null,'0']) assert.throws(()=>faunaPortraitSheet(bad),/Unknown fauna frame/);
const art=worldArt(); assert.equal(art.size,421);
for(const id of names) assert.ok(!art.has(`hearth_fauna_${id}.png`),'64px fauna atlas must not enter the 512-tile world sheet');
for(const id of ['hare','fox','deer']) assert.match(animalOBJ(id),/^# Original/,'legacy OBJ API remains available');
});
test('independent B3D parse validates all vertices, outward winding, normals, inset semantic UVs, normalized weights and bind bounds',()=> {
const unique=new Set();
for(const id of names) {
const bytes=faunaB3D(id),m=parse(bytes); unique.add(hash(bytes));
assert.ok(bytes.length<180000&&bytes.length>20000); assert.deepEqual(m.anim,[0,100,24]);
assert.equal(m.texture,`hearth_fauna_${id}.png`);
assert.equal(m.nodes.filter(n=>!n.parent).length,1); assert.equal(m.nodes[0].name,'Root');
assert.equal(new Set(m.nodes.map(n=>n.name)).size,m.nodes.length);
assert.ok(m.nodes.length>=6&&m.nodes.length<=30); assert.ok(m.vertices.length>=144&&m.vertices.length<=1600);
assert.equal(m.vertices.length%24,0); assert.equal(m.triangles.length,m.vertices.length/2);
const weights=new Float64Array(m.vertices.length),references=new Set();
for(const n of m.nodes) for(const [v,w] of n.weights) {
assert.ok(v>=0&&v<m.vertices.length&&w>0&&w<=1); weights[v]+=w;
}
for(const w of weights) near(w,1);
assert.ok(m.vertices.flat().every(Number.isFinite));
for(const [a,b,c] of m.triangles) {
assert.equal(new Set([a,b,c]).size,3);
for(const v of [a,b,c]) { assert.ok(v>=0&&v<m.vertices.length); references.add(v); }
const vs=[a,b,c].map(i=>m.vertices[i]),normal=cross(sub(vs[1].slice(0,3),vs[0].slice(0,3)),sub(vs[2].slice(0,3),vs[0].slice(0,3)));
assert.ok(Math.hypot(...normal)>1e-6);
const first=Math.floor(a/24)*24,box=m.vertices.slice(first,first+24),center=[0,1,2].map(i=>box.reduce((sum,v)=>sum+v[i],0)/24);
assert.ok(dot(normal,sub(vs[0].slice(0,3),center))>0,`${id}: outward solid winding`);
const tiles=vs.map(v=>v.slice(6).map(n=>Math.floor(n*4)).join()); assert.equal(new Set(tiles).size,1);
for(const v of vs) {
near(Math.hypot(...v.slice(3,6)),1); near(dot(normal,v.slice(3,6))/Math.hypot(...normal),1);
for(const u of v.slice(6)) {
assert.ok(u>0&&u<1); const px=u*64%16; assert.ok(px>=.5&&px<=15.5);
}
}
}
assert.equal(references.size,m.vertices.length);
const bound=faunaBounds(id);
near(bound.min[1],0);
for(let i=0;i<3;i++) {
near(Math.min(...m.vertices.map(v=>v[i]))/10,bound.min[i]);
near(Math.max(...m.vertices.map(v=>v[i]))/10,bound.max[i]);
assert.ok(bound.max[i]>bound.min[i]&&bound.max[i]-bound.min[i]<2);
}
}
assert.equal(unique.size,23);
});
test('every bone contains all 100 real poses; independent skinning proves each clip moves weighted geometry with no root motion or scale keys',()=> {
for(const id of names) {
const m=parse(faunaB3D(id));
for(const n of m.nodes) {
assert.equal(n.keys.length,100);
n.keys.forEach((key,i)=> {
assert.equal(key.frame,i+1); assert.ok([...key.pos,...key.rotation].every(Number.isFinite));
near(Math.hypot(...key.rotation),1);
if(n.name==='Root') { assert.deepEqual(key.pos,[0,0,0]); assert.deepEqual(key.rotation,[1,0,0,0]); }
});
}
for(const [clip,[a,b]] of Object.entries(FAUNA_ANIMATIONS)) {
const poses=Array.from({length:20},(_,i)=>skin(m,a+i));
assert.ok(poses.flat(2).every(Number.isFinite));
let displacement=0;
for(let f=1;f<20;f++) for(let v=0;v<m.vertices.length;v++)
displacement=Math.max(displacement,Math.hypot(...sub(poses[f][v],poses[0][v])));
assert.ok(displacement>.001,`${id} ${clip}: genuine skeletal displacement`);
for(let v=0;v<m.vertices.length;v++) for(let i=0;i<3;i++) near(poses[0][v][i],poses[b-a][v][i],2e-5);
}
const body=m.nodes.find(n=>n.name==='Body');
for(const key of body.keys) near(key.pos[0],body.pos[0]);
}
});
test('authored anatomy and chained joint motion distinguish ears, antlers, wings, fins, spines, mustelids and a twelve-segment earthworm',()=> {
const models=Object.fromEntries(names.map(id=>[id,parse(faunaB3D(id))]));
const node=(id,name)=>models[id].nodes.find(n=>n.name===name);
const extent=(id,name,axis)=> {
const values=node(id,name).weights.map(([v])=>models[id].vertices[v][axis]); return Math.max(...values)-Math.min(...values);
};
assert.ok(extent('hare','Ear_-1',1)>extent('rabbit','Ear_-1',1)*1.5);
assert.ok(faunaBounds('deer').max[1]>faunaBounds('roe_deer').max[1]);
assert.ok(node('deer','Antler_-1').parent===node('deer','Head'));
assert.ok(extent('red_squirrel','Tail',1)>extent('red_squirrel','Tail',0)*2,'raised slender squirrel brush');
near(extent('fox','Tail',1),extent('fox','Tail',0));
assert.ok(extent('wood_mouse','Tail_Tip',2)>extent('bank_vole','Tail_Tip',2)*2);
assert.ok(extent('stoat','Body',2)>extent('weasel','Body',2));
for(const id of ['hare','deer','fox','badger','otter','common_frog','smooth_newt']) {
assert.equal(node(id,'Shin_-1_-1').parent,node(id,'Leg_-1_-1'));
assert.notDeepEqual(node(id,'Leg_-1_-1').keys[24].rotation,node(id,'Leg_1_-1').keys[24].rotation);
}
for(const id of ['robin','blackbird','blue_tit','barn_owl','mallard','bumblebee']) {
const wing=node(id,'Wing_-1'); assert.ok(wing.weights.length);
assert.notDeepEqual(wing.keys[60].rotation,wing.keys[65].rotation);
assert.notDeepEqual(wing.keys[65].rotation,node(id,'Wing_1').keys[65].rotation);
if(id!=='bumblebee') assert.equal(node(id,'Wing_Tip_-1').parent,wing);
}
for(const id of ['otter','smooth_newt','brown_trout']) {
const tail=node(id,'Tail'),tip=node(id,'Tail_Tip'); assert.equal(tip.parent,tail);
assert.notDeepEqual(tail.keys[60].rotation,tail.keys[65].rotation);
}
const segments=models.earthworm.nodes.filter(n=>n.name.startsWith('Segment_')); assert.equal(segments.length,12);
segments.slice(1).forEach((n,i)=>assert.equal(n.parent,segments[i]));
assert.notDeepEqual(segments[0].keys[24].rotation,segments[5].keys[24].rotation);
assert.ok(models.hedgehog.vertices.length>models.bank_vole.vertices.length,'spine geometry, not only a colour change');
});
test('64px semantic RGBA atlases encode exact opaque pixels, distinct species, badger stripes, otter cream and bee bands',()=> {
const distinct=new Set(),pixel=(image,x,y)=>[...image.data.slice((y*64+x)*4,(y*64+x+1)*4)];
const material=(id,name,x,y)=>pixel(faunaTexture(id),FAUNA_MATERIALS.indexOf(name)%4*16+x,Math.floor(FAUNA_MATERIALS.indexOf(name)/4)*16+y);
for(const id of names) {
const image=faunaTexture(id); assert.deepEqual([image.width,image.height,image.data.length],[64,64,16384]);
distinct.add(hash(image.data)); assert.ok(image.data.filter((_,i)=>i%4===3).every(v=>v===255));
const bytes=png(64,(x,y)=>pixel(image,x,y)),parts=[];
assert.deepEqual([...bytes.slice(16,24)],[0,0,0,64,0,0,0,64]);
for(let i=8;i<bytes.length;) { const size=bytes.readUInt32BE(i); if(bytes.toString('ascii',i+4,i+8)==='IDAT') parts.push(bytes.slice(i+8,i+8+size)); i+=size+12; }
const raw=inflateSync(Buffer.concat(parts),{maxOutputLength:16448});
assert.equal(raw.length,16448);
for(let y=0;y<64;y++) { assert.equal(raw[y*257],0); assert.deepEqual(raw.slice(y*257+1,y*257+257),Buffer.from(image.data.slice(y*256,y*256+256))); }
}
assert.equal(distinct.size,23);
assert.notDeepEqual(material('badger','face',1,8),material('badger','face',8,8));
assert.deepEqual(material('badger','face',1,8),material('badger','face',14,8));
assert.notDeepEqual(material('otter','belly',8,8),material('otter','coat',8,8));
assert.notDeepEqual(material('bumblebee','stripe',8,1),material('bumblebee','stripe',8,5));
assert.notDeepEqual(material('mallard','wing',8,8),material('mallard','wing',1,8));
});
test('portrait sheet rasterizes all 23 actual skinned meshes at the selected frame with deterministic padded cells',()=> {
const image=faunaPortraitSheet(); assert.deepEqual([image.width,image.height,image.data.length],[512,768,1572864]);
assert.deepEqual(image,faunaPortraitSheet()); assert.notEqual(hash(image.data),hash(faunaPortraitSheet(65).data));
const cells=new Set();
for(let i=0;i<23;i++) {
const cell=Buffer.alloc(128*128*4);
for(let y=0;y<128;y++) cell.set(image.data.slice(((Math.floor(i/4)*128+y)*512+i%4*128)*4,((Math.floor(i/4)*128+y)*512+i%4*128+128)*4),y*128*4);
cells.add(hash(cell));
const colors=new Set(); for(let j=0;j<cell.length;j+=4) { assert.equal(cell[j+3],255); colors.add(cell.slice(j,j+3).join()); }
assert.ok(colors.size>4,`${names[i]}: actual shaded mesh, not an empty paper tile`);
}
assert.equal(cells.size,23);
});