verify-far-shell-city.mjs 10 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. // Decode actual far-shell GLBs, not generator metadata alone.
  2. import assert from 'node:assert/strict'
  3. import {readFileSync,writeFileSync,existsSync} from 'node:fs'
  4. import {resolve,join} from 'node:path'
  5. import {parseGlb} from './instance-repeated-meshes.mjs'
  6. import {decodePrimitive} from './instance-safe-primitives.mjs'
  7. import {surveyShell,verifyClosedTriangles} from './far-shell-geometry.mjs'
  8. const dir=resolve(process.argv[2]),m=JSON.parse(readFileSync(join(dir,'manifest.json'))),tree=JSON.parse(readFileSync(join(dir,'tileset.json'))),root=resolve(dir,'..')
  9. const input=JSON.parse(readFileSync(join(root,'instanced-active-input.json'))),near=JSON.parse(readFileSync(join(root,'near-estate-pilot-input/seated-plan.json'))),bm=new Map([...input.buildings,...near.buildings].map(b=>[b.id,b])),seen=new Set()
  10. let triangles=0,vertices=0,bytes=0,minNormalDot=1,maxCornerError=0,roofArea=0
  11. const baseline=JSON.parse(readFileSync(join(root,'city-continuous-v8/manifest.json'))),oldRecords=new Map(baseline.records.map(r=>[resolve(root,'city-continuous-v8',r.uri),r]))
  12. const baselineTree=JSON.parse(readFileSync(join(root,'city-continuous-v8/tileset.json'))),rb=tree.root.boundingVolume.box
  13. const allSourceCells=new Set()
  14. function paths(t,base,list=[]){if(t.content)list.push(resolve(base,t.content.uri));for(const n of t.children||[])paths(n,base,list);return list.sort()}
  15. for(const t of tree.root.children){const s=t.boundingVolume.sphere;assert.ok(s);for(let a=0;a<3;a++)assert.ok(s[a]-s[3]>=rb[a]-rb[[3,7,11][a]]-0.01&&s[a]+s[3]<=rb[a]+rb[[3,7,11][a]]+0.01,'root excludes child sphere')}
  16. for(const r of m.records.filter(r=>r.level==='shell')){
  17. const expected=[];const expectedIds=[]
  18. for(const key of r.sourceCells){assert.ok(!allSourceCells.has(key));allSourceCells.add(key);const i=baseline.decisions.findIndex(c=>c.cell===key);assert.ok(i>=0);expected.push(...paths(baselineTree.root.children[i],join(root,'city-continuous-v8')));expectedIds.push(...baseline.decisions[i].buildingIds)}
  19. assert.deepEqual([...r.buildingIds].sort(),expectedIds.sort(),'shell group source IDs differ')
  20. const node=tree.root.children.find(t=>t.content?.uri===r.uri);assert.ok(node);const actual=node.children.flatMap(t=>paths(t,dir));assert.deepEqual(actual.sort(),expected.sort(),'shell local subtree changed')
  21. }
  22. for(const c of m.fallbackCells){assert.ok(!allSourceCells.has(c.cell));allSourceCells.add(c.cell)}
  23. assert.equal(allSourceCells.size,baseline.decisions.length)
  24. const recordMap=new Map(m.records.map(r=>[resolve(dir,r.uri),r])),uris=new Set()
  25. function walk(t){
  26. if(t.children?.length){assert.equal(t.refine,'REPLACE');assert.ok(!t.content||t.children.every(c=>c.content),'content parent cannot refine into empty readiness bypass')}
  27. if(t.content){const p=resolve(dir,t.content.uri),r=recordMap.get(p);assert.ok(r&&existsSync(p));assert.ok(!uris.has(p));uris.add(p);if(r.level!=='shell')assert.ok(oldRecords.has(p),'nonfar model changed')}
  28. for(const c of t.children||[])walk(c)
  29. }
  30. walk(tree.root);assert.equal(uris.size,m.records.length)
  31. for(const r of m.records.filter(r=>r.level==='shell')){
  32. const data=readFileSync(join(dir,r.uri)),{json,bin}=parseGlb(data),translation=json.nodes[0].translation
  33. assert.equal(json.nodes.length,1);assert.equal(json.meshes.length,1);assert.equal(json.meshes[0].primitives.length,2);assert.equal(json.materials.length,2);assert.ok(!json.images&&!json.textures)
  34. bytes+=data.length;const wanted=new Map(),actual=new Map(),geometryById=new Map();let count=0,points=0
  35. const node=tree.root.children.find(t=>t.content?.uri===r.uri);assert.ok(node)
  36. const volume=node.boundingVolume.sphere,rb=tree.root.boundingVolume.box,rootMin=[rb[0]-rb[3],rb[1]-rb[7],rb[2]-rb[11]],rootMax=[rb[0]+rb[3],rb[1]+rb[7],rb[2]+rb[11]]
  37. for(const child of node.children){const s=child.boundingVolume.sphere;assert.ok(s);assert.ok(Math.hypot(...s.slice(0,3).map((v,a)=>v-volume[a]))+s[3]<=volume[3]+0.01,'parent sphere excludes child')}
  38. // Match encoded roof vertices/heights to the uniquely seated surveyed rings.
  39. const spatial=new Map()
  40. for(const id of r.buildingIds){assert.ok(bm.has(id)&&!seen.has(id));seen.add(id);const s=surveyShell(bm.get(id));verifyClosedTriangles([...s.roof,...s.body]);geometryById.set(id,s);wanted.set(id,s.area);actual.set(id,0)
  41. for(const p of s.ring){const key=Math.floor(p[0]/10)+','+Math.floor(p[1]/10);if(!spatial.has(key))spatial.set(key,new Set());spatial.get(key).add(id)}
  42. }
  43. const roofKey=(ps,top)=>JSON.stringify(ps.map(p=>p.slice(0,2)).toSorted((a,b)=>a[0]-b[0]||a[1]-b[1]))+'@'+top
  44. const expectedBody=new Map(),bodyKey=ps=>JSON.stringify(ps.toSorted((a,b)=>a[0]-b[0]||a[1]-b[1]||a[2]-b[2]))
  45. for(const [id,s] of geometryById)for(const tri of s.body){const key=bodyKey(tri);if(!expectedBody.has(key))expectedBody.set(key,[]);expectedBody.get(key).push(id)}
  46. const expectedRoof=new Map()
  47. for(const id of r.buildingIds){const s=surveyShell(bm.get(id));for(const tri of s.roof){const key=roofKey(tri,s.top);if(!expectedRoof.has(key))expectedRoof.set(key,[]);expectedRoof.get(key).push(id)}}
  48. for(const p of json.meshes[0].primitives){const g=decodePrimitive(json,bin,p);points+=g.positions.length/3;count+=g.indices.length/3
  49. for(let i=0;i<g.indices.length;i+=3){const index=g.indices.slice(i,i+3),ps=index.map(j=>g.positions.slice(j*3,j*3+3)),a=ps[1].map((v,k)=>v-ps[0][k]),b=ps[2].map((v,k)=>v-ps[0][k]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],n=Math.hypot(...cross)
  50. assert.ok(n>1e-10,r.uri+' degenerate shell face')
  51. for(const j of index){const normal=g.normals.slice(j*3,j*3+3),dot=normal.reduce((s,v,k)=>s+v*cross[k],0)/n/Math.hypot(...normal);minNormalDot=Math.min(minNormalDot,dot);assert.ok(dot>0.9,r.uri+' reversed normal '+dot)}
  52. const enu=ps.map(q=>[q[0]+translation[0],-(q[2]+translation[2]),q[1]+translation[1]])
  53. for(const q of enu){q.forEach((v,k)=>assert.ok(v>=r.boundsENU.min[k]-0.06&&v<=r.boundsENU.max[k]+0.06&&v>=rootMin[k]-0.06&&v<=rootMax[k]+0.06));assert.ok(Math.hypot(...q.map((v,k)=>v-volume[k]))<=volume[3]+0.06)}
  54. if(p.material!==1){
  55. const q=enu[0],candidates=new Set();for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(const id of spatial.get((Math.floor(q[0]/10)+dx)+','+(Math.floor(q[1]/10)+dy))||[])candidates.add(id)
  56. let found=false
  57. for(const id of candidates){const s=geometryById.get(id),ps=enu.map(v=>{const p=s.ring.reduce((best,q)=>Math.hypot(v[0]-q[0],v[1]-q[1])<Math.hypot(v[0]-best[0],v[1]-best[1])?q:best);const z=Math.abs(v[2]-s.bottom)<Math.abs(v[2]-s.top)?s.bottom:s.top;return [p[0],p[1],z]});if(enu.some((v,i)=>Math.hypot(...v.map((x,a)=>x-ps[i][a]))>0.01))continue;const queue=expectedBody.get(bodyKey(ps));if(queue?.length){queue.pop();found=true;break}}
  58. assert.ok(found,r.uri+' unexpected body triangle');continue
  59. }
  60. const projected=((enu[1][0]-enu[0][0])*(enu[2][1]-enu[0][1])-(enu[1][1]-enu[0][1])*(enu[2][0]-enu[0][0]))/2;assert.ok(projected>0,'roof reversed')
  61. roofArea+=projected
  62. const q=enu[0],candidates=new Set();for(let dx=-1;dx<=1;dx++)for(let dy=-1;dy<=1;dy++)for(const id of spatial.get((Math.floor(q[0]/10)+dx)+','+(Math.floor(q[1]/10)+dy))||[])candidates.add(id)
  63. const matches=[]
  64. for(const id of candidates){const s=surveyShell(bm.get(id));if(Math.abs(q[2]-s.top)>0.01)continue;const e=Math.max(...enu.map(v=>Math.min(...s.ring.map(w=>Math.hypot(v[0]-w[0],v[1]-w[1])))));if(e<0.01)matches.push({id,error:e})}
  65. assert.ok(matches.length,r.uri+' roof has no surveyed owner');matches.sort((a,b)=>a.error-b.error);maxCornerError=Math.max(maxCornerError,matches[0].error)
  66. // Equivalent CAD duplicate rings are one physical roof; compare combined area.
  67. let owner
  68. for(const match of matches){const s=surveyShell(bm.get(match.id)),ps=enu.map(v=>s.ring.reduce((best,q)=>Math.hypot(v[0]-q[0],v[1]-q[1])<Math.hypot(v[0]-best[0],v[1]-best[1])?q:best)),queue=expectedRoof.get(roofKey(ps,s.top));if(queue?.length){owner=queue.pop();break}}
  69. assert.ok(owner,r.uri+' unexpected or duplicated roof triangle');actual.set(owner,actual.get(owner)+projected)
  70. }
  71. }
  72. assert.ok([...expectedBody.values()].every(q=>q.length===0),r.uri+' missing source body triangles')
  73. assert.ok([...expectedRoof.values()].every(q=>q.length===0),r.uri+' missing source roof triangles')
  74. for(const [id,total] of wanted){assert.ok(actual.get(id)>0,id+' missing roof');assert.ok(Math.abs(actual.get(id)-total)<Math.max(0.1,total*0.002),id+' roof area differs from survey')}
  75. assert.equal(count,r.triangles);assert.equal(points,r.vertices);assert.ok(count<=20000&&points<=60000);triangles+=count;vertices+=points
  76. }
  77. for(const c of m.fallbackCells){
  78. for(const id of c.buildingIds){assert.ok(bm.has(id)&&!seen.has(id));seen.add(id)}
  79. const index=baseline.decisions.findIndex(d=>d.cell===c.cell);assert.ok(index>=0)
  80. assert.deepEqual([...c.buildingIds].sort(),[...baseline.decisions[index].buildingIds].sort())
  81. const original=JSON.parse(readFileSync(join(root,'city-continuous-v8/tileset.json'))).root.children[index],fallback=tree.root.children.find(t=>t.extras?.fallbackSourceCell===c.cell)
  82. assert.ok(fallback,c.cell+' fallback missing')
  83. function paths(t,base,list=[]){if(t.content)list.push(resolve(base,t.content.uri));for(const n of t.children||[])paths(n,base,list);return list.sort()}
  84. assert.deepEqual(paths(fallback,dir),paths(original,join(root,'city-continuous-v8')),c.cell+' fallback geometry changed')
  85. }
  86. assert.equal(seen.size,30172);assert.equal(triangles,m.shellTriangles);assert.equal(vertices,m.shellVertices);assert.equal(bytes,m.shellBytes)
  87. let oldFarTriangles=0,oldFarPrimitives=0
  88. for(const r of baseline.records.filter(r=>r.level==='far')){const {json}=parseGlb(readFileSync(resolve(root,'city-continuous-v8',r.uri)));for(const n of json.nodes)if(n.mesh!==undefined)for(const p of json.meshes[n.mesh].primitives){oldFarTriangles+=json.accessors[p.indices].count/3;oldFarPrimitives++}}
  89. const result={passed:true,buildings:seen.size,shellFiles:m.shellFiles,shellTriangles:triangles,shellVertices:vertices,shellBytes:bytes,shellPrimitives:m.shellPrimitives,
  90. baselineFar:{files:baseline.far,triangles:oldFarTriangles,primitives:oldFarPrimitives},minNormalDot,maxSurveyCornerErrorM:maxCornerError,decodedRoofAreaM2:roofArea,
  91. oldLocalUrisUnchanged:true,allUrisReachable:true,renderingValidated:false,fpsValidated:false}
  92. if(process.argv[3])writeFileSync(process.argv[3],JSON.stringify(result,null,2));console.log(JSON.stringify(result))