// One formal city tree: cell-owned distant coarse -> bounded local coarse/fine pairs. // Use instance candidates only where offline payload AND primitive counts beat the // old fine partitions; otherwise reuse the original immutable branch. import assert from 'node:assert/strict' import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs' import {resolve,join,relative} from 'node:path' import {parseGlb} from './instance-repeated-meshes.mjs' const root=resolve('tools/city-massing/city/cad-bim'),inst=resolve(process.argv[2]),out=resolve(process.argv[3]) assert.ok(!existsSync(out),'immutable output exists') const im=JSON.parse(readFileSync(join(inst,'manifest.json'))),it=JSON.parse(readFileSync(join(inst,'tileset.json'))) const current=JSON.parse(readFileSync(join(root,'city-continuous-v5/manifest.json'))) const groups=new Map(),nodes=new Map(it.root.children.map(t=>[t.children[0].content.uri,t])) for(const c of im.cells){if(!groups.has(c.sourceCell))groups.set(c.sourceCell,[]);groups.get(c.sourceCell).push(c)} const children=[],coverage=new Set(),records=[],decisions=[] let farFiles=0,nearFiles=0,fineFiles=0 function load(p){return JSON.parse(readFileSync(p))} function uri(dir,p){return relative(out,resolve(dir,p)).replaceAll('\\','/')} function rebase(tile,dir){ const t=structuredClone(tile) if(t.content)t.content.uri=uri(dir,t.content.uri) if(t.children?.length){ t.children=t.children.map(c=>rebase(c,dir)) if(t.boundingVolume.box){const b=t.boundingVolume.box;t.boundingVolume={sphere:[...b.slice(0,3),Math.hypot(b[3],b[7],b[11])]}} t.geometricError=1000000;t.refine='REPLACE' } return t } function countPrimitives(path){const {json}=parseGlb(readFileSync(path));return json.nodes.reduce((s,n)=>s+(n.mesh===undefined?0:json.meshes[n.mesh].primitives.length),0)} function sphere(bounds){const center=bounds.min.map((v,a)=>(v+bounds.max[a])/2),half=bounds.min.map((v,a)=>(bounds.max[a]-v)/2);return [...center,Math.hypot(...half)]} for(const s of current.sources){ const dir=join(root,s.source),sm=load(join(dir,'manifest.json')),st=load(join(dir,'tileset.json')) const batch=s.source.split('/')[0],baseDir=join(root,batch),base=load(join(baseDir,'manifest.json')) const baseCells=new Map(base.cells.map(c=>[c.id,c])) assert.equal(sm.cells.length,st.root.children.length) for(let index=0;indexa+readFileSync(join(dir,p)).length,0) const oldPrimitives=oldFiles.reduce((a,p)=>a+countPrimitives(join(dir,p)),0) const candidateBytes=candidates.reduce((a,c)=>a+c.lod0.bytes,0) const candidatePrimitives=candidates.reduce((a,c)=>a+c.lod0.drawPrimitives,0) const candidateIds=candidates.flatMap(c=>c.buildingIds),candidateSet=new Set(candidateIds) const exactCoverage=candidateSet.size===candidateIds.length&&candidateSet.size===original.buildingIds.length&&original.buildingIds.every(i=>candidateSet.has(i)) const use=exactCoverage&&candidates.some(c=>c.instanceGroups>0)&&candidateBytesrebase(nodes.get(c.lod0.uri),inst)):rebase(st.root.children[index],dir).children const bounds=structuredClone(baseCell.boundsENU) if(use)for(const c of candidates)for(let a=0;a<3;a++){ bounds.min[a]=Math.min(bounds.min[a],c.boundsENU.min[a]);bounds.max[a]=Math.max(bounds.max[a],c.boundsENU.max[a]) } const volume=sphere(bounds),coarseWhole=baseCell.lod1.triangles<=20000 if(coarseWhole){ // Admit all local coarse branches at one shared camera gate, protecting // residual coverage when only some fine request volumes are admitted. const gate={sphere:[...volume.slice(0,3),volume[3]+900]} for(const t of local){if(t.children?.length)t.viewerRequestVolume=structuredClone(gate)} const outer={boundingVolume:{sphere:volume},geometricError:1000000,refine:'REPLACE', viewerRequestVolume:{sphere:[...volume.slice(0,3),volume[3]+6000]}, content:{uri:uri(baseDir,baseCell.lod1.uri)},children:[{ boundingVolume:{sphere:volume},geometricError:1000000,refine:'REPLACE',viewerRequestVolume:gate,children:local}]} children.push(outer);farFiles++ records.push({uri:outer.content.uri,triangles:baseCell.lod1.triangles,level:'far',buildingIds:original.buildingIds}) }else{ // Exactly two legacy coarse cells exceed the whole-cell budget; retain // bounded partition granularity rather than exporting a larger GLB. children.push({boundingVolume:{sphere:volume},geometricError:1000000,refine:'REPLACE',children:local}) } const localOwners=new Map() for(const t of local){ const uri=t.content?.uri const owner=use?candidates.find(c=>uri?.endsWith('/'+c.lod1.uri.split('/').at(-1))):null if(use)assert.ok(owner,'missing candidate pair ownership') localOwners.set(t,owner?owner.buildingIds:original.buildingIds) } function collect(t,ownerIds){if(t.content){const level=t.children?.length?'near':'fine';if(level==='near')nearFiles++;else fineFiles++; records.push({uri:t.content.uri,level,sourceCell:key,buildingIds:ownerIds})}for(const c of t.children||[])collect(c,ownerIds)} local.forEach(t=>collect(t,localOwners.get(t))) decisions.push({cell:key,buildingIds:original.buildingIds,useInstancing:use,oldBytes,candidateBytes,oldPrimitives,candidatePrimitives, instanceGroups:use?candidates.reduce((s,c)=>s+c.instanceGroups,0):0,instancedBuildings:use?candidates.reduce((s,c)=>s+c.instancedBuildings,0):0, wholeCellCoarse:coarseWhole}) } } assert.equal(coverage.size,30172) mkdirSync(out,{recursive:true}) const currentTree=load(join(root,'city-continuous-v5/tileset.json')) writeFileSync(join(out,'tileset.json'),JSON.stringify({asset:{version:'1.1',generator:'Cell coarse + bounded translation-instance candidates'},geometricError:1000000, root:{transform:currentTree.root.transform,boundingVolume:currentTree.root.boundingVolume,geometricError:1000000,refine:'REPLACE',children}},null,2)) const report={buildings:coverage.size,farFiles,nearFiles,fineFiles,decisions,records,sourceInstancing:inst, retainedCells:decisions.filter(d=>!d.useInstancing).length, payloadBeforeAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.oldBytes,0), payloadAfterAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.candidateBytes,0), primitivesBeforeAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.oldPrimitives,0), primitivesAfterAcceptedCells:decisions.filter(d=>d.useInstancing).reduce((s,d)=>s+d.candidatePrimitives,0), candidateOnly:true,instancedCells:decisions.filter(d=>d.useInstancing).length,instancedBuildings:decisions.reduce((s,d)=>s+d.instancedBuildings,0), policy:'Far coarse +6000m, whole-cell shared local coarse gate +900m, original fine +250/new +350m; bounding sphere half-diagonal expands these camera gates. Normal-invariant shared primitives retain exact TRS, unsafe slopes keep ordinary transforms. No runtime/FPS claim.'} writeFileSync(join(out,'manifest.json'),JSON.stringify(report,null,2)) console.log(JSON.stringify({out,buildings:report.buildings,farFiles,nearFiles,fineFiles,instancedCells:report.instancedCells,instancedBuildings:report.instancedBuildings}))