// Instance only genuinely shared primitives whose normals are invariant under every // instance's scale (within 0.08 degrees). Sloped/bevel surfaces keep ordinary node TRS. // Retained unused POSITION vertices conservatively enclose all rotated/scaled copies // in Cesium's translation-only command bound; they are NEVER referenced by triangles. import assert from 'node:assert/strict' import draco3d from 'draco3d' import {parseGlb,packGlb,meshSignature} from './instance-repeated-meshes.mjs' const decoder=await draco3d.createDecoderModule({}) export function decodePrimitive(json,bin,p){ const e=p.extensions?.KHR_draco_mesh_compression if(!e){ function values(index){const a=json.accessors[index],v=json.bufferViews[a.bufferView],n={VEC2:2,VEC3:3,SCALAR:1}[a.type],size={5126:4,5125:4,5123:2}[a.componentType],start=(v.byteOffset||0)+(a.byteOffset||0),stride=v.byteStride||size*n const read=a.componentType===5126?'readFloatLE':a.componentType===5125?'readUInt32LE':'readUInt16LE' return Array.from({length:a.count*n},(_,i)=>bin[read](start+Math.floor(i/n)*stride+(i%n)*size))} return{positions:values(p.attributes.POSITION),normals:values(p.attributes.NORMAL),indices:values(p.indices)} } const v=json.bufferViews[e.bufferView],b=bin.subarray(v.byteOffset||0,(v.byteOffset||0)+v.byteLength) const dec=new decoder.Decoder(),db=new decoder.DecoderBuffer(),m=new decoder.Mesh();let status try{db.Init(new Int8Array(b.buffer,b.byteOffset,b.byteLength),b.byteLength);status=dec.DecodeBufferToMesh(db,m);assert.ok(status.ok(),status.error_msg()) function attr(semantic){const a=dec.GetAttributeByUniqueId(m,e.attributes[semantic]),size=m.num_points()*12,ptr=decoder._malloc(size) try{assert.ok(dec.GetAttributeDataArrayForAllPoints(m,a,decoder.DT_FLOAT32,size,ptr));return [...new Float32Array(decoder.HEAPF32.buffer,ptr,m.num_points()*3)]}finally{decoder._free(ptr)}} const size=m.num_faces()*12,ptr=decoder._malloc(size);let indices try{assert.ok(dec.GetTrianglesUInt32Array(m,size,ptr));indices=[...new Uint32Array(decoder.HEAPU32.buffer,ptr,m.num_faces()*3)]}finally{decoder._free(ptr)} return{positions:attr('POSITION'),normals:attr('NORMAL'),indices} }finally{if(status)decoder.destroy(status);decoder.destroy(m);decoder.destroy(db);decoder.destroy(dec)} } export function rotate(v,q){const [x,y,z,w]=q,u=[y*v[2]-z*v[1],z*v[0]-x*v[2],x*v[1]-y*v[0]],uu=[y*u[2]-z*u[1],z*u[0]-x*u[2],x*u[1]-y*u[0]];return v.map((a,i)=>a+2*(w*u[i]+uu[i]))} export function safeNormalDot(normals,scale){let min=1;for(let i=0;iv*scale[j]),b=n.map((v,j)=>v/scale[j]);const dot=a.reduce((s,v,j)=>s+v*b[j],0)/Math.hypot(...a)/Math.hypot(...b);min=Math.min(min,dot)}return min} export function instanceSafePrimitives(bytes){ const {json,bin}=parseGlb(bytes) assert.equal(json.scenes.length,1);assert.ok(!json.animations?.length&&!json.skins?.length&&!json.images?.length) assert.deepEqual([...json.scenes[json.scene||0].nodes].sort((a,b)=>a-b),json.nodes.map((_,i)=>i)) assert.ok(json.accessors.every(a=>!a.sparse));assert.ok((json.extensionsUsed||[]).every(e=>e==='KHR_draco_mesh_compression')) const sourceNodes=json.nodes,groups=new Map(),remaining=new Map(sourceNodes.map(n=>[n,[]])) let oldPrimitives=0,minNormalDot=1 for(const node of sourceNodes){assert.ok(node.mesh!==undefined&&!node.matrix&&!node.children&&!node.weights&&!node.extensions) for(const p of json.meshes[node.mesh].primitives){assert.ok(!p.targets&&(p.mode??4)===4);assert.ok(Object.keys(p.attributes).every(k=>['POSITION','NORMAL'].includes(k)));const key=meshSignature(json,bin,{primitives:[p]});if(!groups.has(key))groups.set(key,[]);groups.get(key).push({node,p});oldPrimitives++}} const chunks=[bin],nodes=[],meshes=[],records=[];let length=bin.length function accessor(values,type,integer=false){const comp={SCALAR:1,VEC3:3,VEC4:4}[type],pad=(-length)&3;if(pad){chunks.push(Buffer.alloc(pad));length+=pad}const b=Buffer.alloc(values.length*4);values.forEach((v,i)=>{assert.ok(Number.isFinite(v));integer?b.writeUInt32LE(v,i*4):b.writeFloatLE(v,i*4)}) const vi=json.bufferViews.length;json.bufferViews.push({buffer:0,byteOffset:length,byteLength:b.length});chunks.push(b);length+=b.length const i=json.accessors.length,a={bufferView:vi,componentType:integer?5125:5126,type,count:values.length/comp};if(type==='VEC3'){a.min=[0,1,2].map(k=>Math.min(...values.filter((_,i)=>i%3===k)));a.max=[0,1,2].map(k=>Math.max(...values.filter((_,i)=>i%3===k)))}json.accessors.push(a);return i} for(const group of groups.values()){ const geom=group.length>=2?decodePrimitive(json,bin,group[0].p):null const eligible=geom&&group.every(({node})=>{const s=node.scale||[1,1,1],q=node.rotation||[0,0,0,1];assert.ok(s.every(v=>v>0)&&Math.abs(Math.hypot(...q)-1)<1e-5);return safeNormalDot(geom.normals,s)>=0.999999}) if(!eligible){for(const {node,p} of group)remaining.get(node).push(structuredClone(p));continue} let radius=0;for(const {node}of group){const s=node.scale||[1,1,1];for(let i=0;iv*s[a])))}radius+=0.005 const originalCount=geom.positions.length/3,positions=[...geom.positions],normals=[...geom.normals] for(const x of [-radius,radius])for(const y of [-radius,radius])for(const z of [-radius,radius]){positions.push(x,y,z);normals.push(0,1,0)} const p={attributes:{POSITION:accessor(positions,'VEC3'),NORMAL:accessor(normals,'VEC3')},indices:accessor(geom.indices,'SCALAR',true),material:group[0].p.material} meshes.push({primitives:[p]}) const translations=group.flatMap(g=>g.node.translation||[0,0,0]),rotations=group.flatMap(g=>g.node.rotation||[0,0,0,1]),scales=group.flatMap(g=>g.node.scale||[1,1,1]) nodes.push({mesh:meshes.length-1,extensions:{EXT_mesh_gpu_instancing:{attributes:{TRANSLATION:accessor(translations,'VEC3'),ROTATION:accessor(rotations,'VEC4'),SCALE:accessor(scales,'VEC3')}}},extras:{instanceNames:group.map(g=>g.node.name),sourceVertexCount:originalCount,boundPaddingVertices:8}}) const dot=Math.min(...group.map(g=>safeNormalDot(geom.normals,g.node.scale||[1,1,1])));minNormalDot=Math.min(minNormalDot,dot) records.push({node:nodes.length-1,count:group.length,names:group.map(g=>g.node.name),triangles:geom.indices.length/3,minNormalDot:dot}) } for(const node of sourceNodes){const ps=remaining.get(node);if(!ps.length)continue;meshes.push({primitives:ps});nodes.push({...node,mesh:meshes.length-1})} json.nodes=nodes;json.meshes=meshes;json.scenes[json.scene||0].nodes=nodes.map((_,i)=>i) const aid=new Set(),vid=new Set(),mid=new Set() for(const mesh of meshes)for(const p of mesh.primitives){Object.values(p.attributes).forEach(i=>aid.add(i));aid.add(p.indices);mid.add(p.material);if(p.extensions?.KHR_draco_mesh_compression)vid.add(p.extensions.KHR_draco_mesh_compression.bufferView)} for(const n of nodes)Object.values(n.extensions?.EXT_mesh_gpu_instancing?.attributes||{}).forEach(i=>aid.add(i)) for(const i of aid)if(json.accessors[i].bufferView!==undefined)vid.add(json.accessors[i].bufferView) const aa=[...aid],am=new Map(aa.map((v,i)=>[v,i])),vv=[...vid],vm=new Map(),all=Buffer.concat(chunks),views=[],payload=[];let off=0 for(const i of vv){const v=json.bufferViews[i],pad=(-off)&3;if(pad){payload.push(Buffer.alloc(pad));off+=pad}vm.set(i,views.length);views.push({...v,byteOffset:off});payload.push(all.subarray(v.byteOffset||0,(v.byteOffset||0)+v.byteLength));off+=v.byteLength} const mm=[...mid],matm=new Map(mm.map((v,i)=>[v,i]));json.materials=mm.map(i=>json.materials[i]);json.accessors=aa.map(i=>json.accessors[i]);json.accessors.forEach(a=>{if(a.bufferView!==undefined)a.bufferView=vm.get(a.bufferView)}) for(const mesh of meshes)for(const p of mesh.primitives){p.attributes=Object.fromEntries(Object.entries(p.attributes).map(([k,i])=>[k,am.get(i)]));p.indices=am.get(p.indices);p.material=matm.get(p.material);if(p.extensions?.KHR_draco_mesh_compression)p.extensions.KHR_draco_mesh_compression.bufferView=vm.get(p.extensions.KHR_draco_mesh_compression.bufferView)} for(const n of nodes)if(n.extensions?.EXT_mesh_gpu_instancing)n.extensions.EXT_mesh_gpu_instancing.attributes=Object.fromEntries(Object.entries(n.extensions.EXT_mesh_gpu_instancing.attributes).map(([k,i])=>[k,am.get(i)])) json.bufferViews=views;json.buffers=[{byteLength:off}] if(records.length){json.extensionsUsed=[...new Set([...(json.extensionsUsed||[]),'EXT_mesh_gpu_instancing'])];json.extensionsRequired=[...new Set([...(json.extensionsRequired||[]),'EXT_mesh_gpu_instancing'])]} return{bytes:packGlb(json,Buffer.concat(payload)),report:{originalNodes:sourceNodes.length,outputNodes:nodes.length,oldPrimitives,outputPrimitives:nodes.reduce((s,n)=>s+meshes[n.mesh].primitives.length,0),instanceGroups:records.length,instancedPlacements:records.reduce((s,r)=>s+r.count,0),records,minNormalDot}} }