// 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;ig.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.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])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)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))