247 lines
15 KiB
JavaScript
Executable File
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);
|
|
});
|