| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192 |
- // Decode actual far-shell GLBs, not generator metadata alone.
- import assert from 'node:assert/strict'
- import {readFileSync,writeFileSync,existsSync} from 'node:fs'
- import {resolve,join} from 'node:path'
- import {parseGlb} from './instance-repeated-meshes.mjs'
- import {decodePrimitive} from './instance-safe-primitives.mjs'
- import {surveyShell,verifyClosedTriangles} from './far-shell-geometry.mjs'
- 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,'..')
- 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()
- let triangles=0,vertices=0,bytes=0,minNormalDot=1,maxCornerError=0,roofArea=0
- 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]))
- const baselineTree=JSON.parse(readFileSync(join(root,'city-continuous-v8/tileset.json'))),rb=tree.root.boundingVolume.box
- const allSourceCells=new Set()
- 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()}
- 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')}
- for(const r of m.records.filter(r=>r.level==='shell')){
- const expected=[];const expectedIds=[]
- 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)}
- assert.deepEqual([...r.buildingIds].sort(),expectedIds.sort(),'shell group source IDs differ')
- 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')
- }
- for(const c of m.fallbackCells){assert.ok(!allSourceCells.has(c.cell));allSourceCells.add(c.cell)}
- assert.equal(allSourceCells.size,baseline.decisions.length)
- const recordMap=new Map(m.records.map(r=>[resolve(dir,r.uri),r])),uris=new Set()
- function walk(t){
- 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')}
- 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')}
- for(const c of t.children||[])walk(c)
- }
- walk(tree.root);assert.equal(uris.size,m.records.length)
- for(const r of m.records.filter(r=>r.level==='shell')){
- const data=readFileSync(join(dir,r.uri)),{json,bin}=parseGlb(data),translation=json.nodes[0].translation
- 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)
- bytes+=data.length;const wanted=new Map(),actual=new Map(),geometryById=new Map();let count=0,points=0
- const node=tree.root.children.find(t=>t.content?.uri===r.uri);assert.ok(node)
- 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]]
- 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')}
- // Match encoded roof vertices/heights to the uniquely seated surveyed rings.
- const spatial=new Map()
- 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)
- 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)}
- }
- 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
- const expectedBody=new Map(),bodyKey=ps=>JSON.stringify(ps.toSorted((a,b)=>a[0]-b[0]||a[1]-b[1]||a[2]-b[2]))
- 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)}
- const expectedRoof=new Map()
- 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)}}
- for(const p of json.meshes[0].primitives){const g=decodePrimitive(json,bin,p);points+=g.positions.length/3;count+=g.indices.length/3
- 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)
- assert.ok(n>1e-10,r.uri+' degenerate shell face')
- 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)}
- const enu=ps.map(q=>[q[0]+translation[0],-(q[2]+translation[2]),q[1]+translation[1]])
- 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)}
- if(p.material!==1){
- 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)
- let found=false
- 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}}
- assert.ok(found,r.uri+' unexpected body triangle');continue
- }
- 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')
- roofArea+=projected
- 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)
- const matches=[]
- 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})}
- 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)
- // Equivalent CAD duplicate rings are one physical roof; compare combined area.
- let owner
- 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}}
- assert.ok(owner,r.uri+' unexpected or duplicated roof triangle');actual.set(owner,actual.get(owner)+projected)
- }
- }
- assert.ok([...expectedBody.values()].every(q=>q.length===0),r.uri+' missing source body triangles')
- assert.ok([...expectedRoof.values()].every(q=>q.length===0),r.uri+' missing source roof triangles')
- 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')}
- assert.equal(count,r.triangles);assert.equal(points,r.vertices);assert.ok(count<=20000&&points<=60000);triangles+=count;vertices+=points
- }
- for(const c of m.fallbackCells){
- for(const id of c.buildingIds){assert.ok(bm.has(id)&&!seen.has(id));seen.add(id)}
- const index=baseline.decisions.findIndex(d=>d.cell===c.cell);assert.ok(index>=0)
- assert.deepEqual([...c.buildingIds].sort(),[...baseline.decisions[index].buildingIds].sort())
- 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)
- assert.ok(fallback,c.cell+' fallback missing')
- 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()}
- assert.deepEqual(paths(fallback,dir),paths(original,join(root,'city-continuous-v8')),c.cell+' fallback geometry changed')
- }
- assert.equal(seen.size,30172);assert.equal(triangles,m.shellTriangles);assert.equal(vertices,m.shellVertices);assert.equal(bytes,m.shellBytes)
- let oldFarTriangles=0,oldFarPrimitives=0
- 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++}}
- const result={passed:true,buildings:seen.size,shellFiles:m.shellFiles,shellTriangles:triangles,shellVertices:vertices,shellBytes:bytes,shellPrimitives:m.shellPrimitives,
- baselineFar:{files:baseline.far,triangles:oldFarTriangles,primitives:oldFarPrimitives},minNormalDot,maxSurveyCornerErrorM:maxCornerError,decodedRoofAreaM2:roofArea,
- oldLocalUrisUnchanged:true,allUrisReachable:true,renderingValidated:false,fpsValidated:false}
- if(process.argv[3])writeFileSync(process.argv[3],JSON.stringify(result,null,2));console.log(JSON.stringify(result))
|