rushabhcodes/fidget-counter

A tscircuit package created by rushabhcodes.

Version
1.0.31
License
unset
Stars
0

validation/apply-model-materials.mjs

// Restore per-part PBR finishes lost by tscircuit's OBJ-to-GLB converter.
// Split index ranges only: no vertices, normals, indices, transforms or copper
// buffers are edited. Cross-check source OBJ triangles before assigning finishes.
import assert from 'node:assert/strict'
import {readFileSync,writeFileSync} from 'node:fs'
import {createHash} from 'node:crypto'
const file=process.argv[2];assert(file,'Pass the generated GLB path')
const hash=b=>createHash('sha256').update(b).digest('hex')
const manifestBytes=readFileSync('mechanical/Circuit_Models/manifest.json')
const manifest=JSON.parse(manifestBytes),bytes=readFileSync(file)
assert.equal(bytes.readUInt32LE(0),0x46546c67);assert.equal(bytes.readUInt32LE(4),2)
const jsonLength=bytes.readUInt32LE(12),g=JSON.parse(bytes.subarray(20,20+jsonLength))
const binStart=20+jsonLength,bin=bytes.subarray(binStart+8,binStart+8+bytes.readUInt32LE(binStart))
assert.equal(bytes.readUInt32LE(binStart+4),0x004e4942)
const geometrySha256=hash(bin),manifestSha256=hash(manifestBytes)
if(g.extras?.fidgetModelMaterials?.manifestSha256===manifestSha256){
 assert.equal(g.extras.fidgetModelMaterials.geometrySha256,g.extras.fidgetSourceNormals?.originalBinarySha256??g.extras.fidgetLosslessCompact?.originalBinarySha256??geometrySha256)
 if(g.extras.fidgetSourceNormals&&g.extras.fidgetLosslessCompact)assert.equal(g.extras.fidgetSourceNormals.restoredBinarySha256,g.extras.fidgetLosslessCompact.originalBinarySha256)
 assert.equal(g.extras.fidgetLosslessCompact?.compactedBinarySha256??g.extras.fidgetSourceNormals?.restoredBinarySha256??geometrySha256,geometrySha256)
 console.log('Verified existing PBR material pass; geometry buffer unchanged');process.exit(0)
}
const finish={steel:[.9,.3],polished:[1,.16],plastic:[0,.4],nylon:[0,.52],rubber:[0,.72]}
let models=0,materials=0
for(const node of g.nodes.filter(n=>n.name?.startsWith('BEARING_V5_'))){
 const name=node.name.slice('BEARING_V5_'.length),groups=manifest.materialGroups[name]
 assert(groups?.length,`Missing source material groups: ${name}`)
 const mesh=g.meshes[node.mesh]
 if(manifest.assetFormat==='glb'){
  const expected=new Map(),actual=new Map()
  const key=part=>JSON.stringify([part.color,part.finish])
  for(const part of groups)expected.set(key(part),(expected.get(key(part))??0)+part.face_count*3)
  for(const primitive of mesh.primitives){
   const previous=g.materials[primitive.material].pbrMetallicRoughness?.baseColorFactor??[1,1,1,1]
   const matching=groups.filter(part=>part.color.every((v,i)=>Math.abs(v-previous[i])<.005))
   assert(matching.length,`Unmatched GLB material: ${name}`)
   assert.equal(new Set(matching.map(key)).size,1,`Ambiguous GLB material: ${name}`)
   const part=matching[0],count=primitive.indices===undefined?g.accessors[primitive.attributes.POSITION].count:g.accessors[primitive.indices].count
   actual.set(key(part),(actual.get(key(part))??0)+count)
   const [metallicFactor,roughnessFactor]=finish[part.finish]
   primitive.material=g.materials.length
   g.materials.push({name:`V5_${name}_${matching.map(p=>p.label).join('_')}`,pbrMetallicRoughness:{baseColorFactor:[...part.color,1],metallicFactor,roughnessFactor},alphaMode:'OPAQUE'})
   materials++
  }
  assert.deepEqual([...actual.entries()].sort(),[...expected.entries()].sort(),`GLB material triangle counts changed: ${name}`)
  models++;continue
 }
 assert.equal(mesh.primitives.length,1,`Unexpected GLB hierarchy: ${name}`)
 const primitive=mesh.primitives[0],indices=g.accessors[primitive.indices],view=g.bufferViews[indices.bufferView]
 assert.equal(indices.type,'SCALAR');assert([5123,5125].includes(indices.componentType))
 assert.equal(primitive.mode??4,4);assert(!view.byteStride)
 const size=indices.componentType===5125?4:2
 const indexStart=(view.byteOffset??0)+(indices.byteOffset??0)
 const indexAt=i=>size===4?bin.readUInt32LE(indexStart+i*size):bin.readUInt16LE(indexStart+i*size)
 const pos=g.accessors[primitive.attributes.POSITION],pv=g.bufferViews[pos.bufferView]
 assert.equal(pos.componentType,5126);assert.equal(pos.type,'VEC3')
 const positionAt=i=>[0,1,2].map(axis=>bin.readFloatLE((pv.byteOffset??0)+(pos.byteOffset??0)+i*(pv.byteStride??12)+axis*4))
 const vertices=[],faces=[]
 for(const line of readFileSync(`mechanical/Circuit_Models/${name}.obj`,'utf8').split('\n')){
  if(line.startsWith('v '))vertices.push(line.split(/\s+/).slice(1,4).map(Number))
  else if(line.startsWith('f '))faces.push(line.split(/\s+/).slice(1).map(s=>Number(s.split('/')[0])-1))
 }
 assert.equal(indices.count,faces.length*3,`Changed triangle count: ${name}`)
 assert.equal(groups.reduce((n,part)=>n+part.face_count,0)*3,indices.count)
 let nextFace=0
 for(const part of groups){
  assert.equal(part.first_face,nextFace,`Non-contiguous material ranges: ${name}`)
  assert(part.face_count>0);nextFace+=part.face_count
 }
 assert.equal(nextFace*3,indices.count)
 mesh.primitives=groups.map(part=>{
  // Three source triangles per part prove that ranges refer to the intended
  // component. Compare unordered corners to allow the required handedness flip.
  for(const faceIndex of new Set([part.first_face,part.first_face+Math.floor(part.face_count/2),part.first_face+part.face_count-1])){
   const expected=faces[faceIndex].map(i=>{const [x,y,z]=vertices[i];return [-x,z,y]})
   const actual=[0,1,2].map(i=>positionAt(indexAt(faceIndex*3+i)))
   for(const point of expected)assert(actual.some(p=>p.every((v,a)=>Math.abs(v-point[a])<.00002)),`Triangle ordering changed: ${name}/${part.label}`)
  }
  const [metallicFactor,roughnessFactor]=finish[part.finish]??[];assert(Number.isFinite(metallicFactor),part.finish)
  const material=g.materials.length;g.materials.push({name:`V5_${name}_${part.label}`,pbrMetallicRoughness:{baseColorFactor:[...part.color,1],metallicFactor,roughnessFactor},alphaMode:'OPAQUE'})
  const accessor=g.accessors.length
  const {min,max,...base}=indices
  g.accessors.push({...base,byteOffset:(indices.byteOffset??0)+part.first_face*3*size,count:part.face_count*3})
  assert((indices.byteOffset??0)+(part.first_face+part.face_count)*3*size<=view.byteLength)
  materials++;return {...primitive,indices:accessor,material}
 })
 models++
}
assert.equal(models,manifest.modelsPerSelectedStyle)
g.extras={...g.extras,fidgetModelMaterials:{manifestSha256,geometrySha256,models,materials,logicalMaterialGroups:g.nodes.filter(n=>n.name?.startsWith("BEARING_V5_")).reduce((sum,n)=>sum+manifest.materialGroups[n.name.slice("BEARING_V5_".length)].length,0)}}
let json=Buffer.from(JSON.stringify(g));if(json.length%4)json=Buffer.concat([json,Buffer.alloc(4-json.length%4,0x20)])
const header=Buffer.alloc(20);header.writeUInt32LE(0x46546c67,0);header.writeUInt32LE(2,4);header.writeUInt32LE(20+json.length+8+bin.length,8);header.writeUInt32LE(json.length,12);header.writeUInt32LE(0x4e4f534a,16)
const bh=Buffer.alloc(8);bh.writeUInt32LE(bin.length);bh.writeUInt32LE(0x004e4942,4)
const output=Buffer.concat([header,json,bh,bin]);assert.equal(hash(output.subarray(20+json.length+8)),geometrySha256)
writeFileSync(file,output)
console.log(`Applied ${materials} PBR finishes to ${models} models; geometry buffer SHA256 unchanged: ${geometrySha256}`)