// Light distant shells + exact cell-owned v8 fallback branches; no approximate boxes. import assert from 'node:assert/strict' import {readFileSync,writeFileSync,mkdirSync,existsSync} from 'node:fs' import {resolve,join,relative} from 'node:path' import draco3d from 'draco3d' import {surveyShell,verifyClosedTriangles} from './far-shell-geometry.mjs' import {packGlb,parseGlb} from './instance-repeated-meshes.mjs' const root=resolve('tools/city-massing/city/cad-bim'),out=join(root,process.argv[2]||'city-far-shell-v4'),source=join(root,'city-continuous-v8') assert.ok(!existsSync(out),'immutable generation exists') const load=p=>JSON.parse(readFileSync(p,'utf8')) const tree=load(join(source,'tileset.json')),oldManifest=load(join(source,'manifest.json')),active=load(join(root,'instanced-active-input.json')),near=load(join(root,'near-estate-pilot-input/seated-plan.json')) const bm=new Map([...active.buildings,...near.buildings].map(b=>[b.id,b])),cellNodes=new Map(),cellInfo=new Map(),shells=new Map() assert.equal(tree.root.children.length,oldManifest.decisions.length) oldManifest.decisions.forEach((c,i)=>{cellNodes.set(c.cell,tree.root.children[i]);cellInfo.set(c.cell,c)}) const groupCells=[],assigned=new Set() for(const batch of ['pilot','batch-urban8000-dedup','batch-urban8000b','batch-urban8000c','batch-urban-final','batch-urban-tail','batch-unfitted-fallback']){ const base=load(join(root,'lod-'+batch+'-roofed2/tileset.json')) for(const p of base.root.children){ const keys=p.children.map(c=>batch+'-'+c.content.uri.replace(/-lod0\.glb$/,'')) keys.forEach(k=>{assert.ok(cellNodes.has(k),k);assert.ok(!assigned.has(k),k);assigned.add(k)}) groupCells.push(keys) } } for(const key of cellNodes.keys())if(!assigned.has(key)){assert.ok(key.startsWith('near-estate-pilot-v6-'));groupCells.push([key]);assigned.add(key)} assert.equal(assigned.size,532) const rejected=process.argv[3]?load(resolve(process.argv[3])):null const unsafeCells=new Set(rejected?.cells||[]),unsafeBuildings=[] for(const key of unsafeCells)assert.ok(cellNodes.has(key),'unknown rejected cell '+key) for(const [key,c] of cellInfo)for(const id of c.buildingIds){ assert.ok(bm.has(id),id) try{const shell=surveyShell(bm.get(id));verifyClosedTriangles([...shell.roof,...shell.body]);shells.set(id,shell)} catch(error){unsafeCells.add(key);unsafeBuildings.push({id,cell:key,reason:error.message})} } const cost=key=>cellInfo.get(key).buildingIds.reduce((s,id)=>s+shells.get(id).triangles,0) function split(keys){ if(keys.reduce((s,k)=>s+cost(k),0)<=20000)return[keys] assert.ok(keys.length>1,'single shell cell over budget') const center=k=>{const t=cellNodes.get(k),v=t.boundingVolume.sphere;assert.ok(v);return v.slice(0,3)} const span=[0,1].map(a=>Math.max(...keys.map(k=>center(k)[a]))-Math.min(...keys.map(k=>center(k)[a]))) const axis=span[0]>span[1]?0:1,sorted=[...keys].sort((a,b)=>center(a)[axis]-center(b)[axis]),mid=sorted.length>>1 return [...split(sorted.slice(0,mid)),...split(sorted.slice(mid))] } const groups=groupCells.map(keys=>keys.filter(k=>!unsafeCells.has(k))).filter(keys=>keys.length).flatMap(split),encoderModule=await draco3d.createEncoderModule({}),decoder=await draco3d.createDecoderModule({}),children=[],records=[],seen=new Set() mkdirSync(out,{recursive:true}) function rebase(t){const clone=structuredClone(t);if(t.content)clone.content.uri=relative(out,resolve(source,t.content.uri)).replaceAll('\\','/');if(t.children)clone.children=t.children.map(rebase);delete clone.viewerRequestVolume;return clone} function ancestorBounds(keys,ids){const min=[Infinity,Infinity,Infinity],max=[-Infinity,-Infinity,-Infinity] for(const key of keys){const sphere=cellNodes.get(key).boundingVolume.sphere;for(let a=0;a<3;a++){min[a]=Math.min(min[a],sphere[a]-sphere[3]);max[a]=Math.max(max[a],sphere[a]+sphere[3])}} for(const id of ids)for(const tri of [...shells.get(id).roof,...shells.get(id).body])for(const p of tri)for(let a=0;a<3;a++){min[a]=Math.min(min[a],p[a]);max[a]=Math.max(max[a],p[a])} return {min,max}} for(let index=0;indexcellInfo.get(k).buildingIds),bounds=ancestorBounds(keys,ids),anchor=bounds.min.map((v,a)=>(v+bounds.max[a])/2) ids.forEach(id=>{assert.ok(!seen.has(id));seen.add(id)}) const chunks=[],views=[],accessors=[],primitives=[];let offset=0,triangles=0,vertices=0 for(const kind of ['body','roof']){ const positions=[],normals=[],indices=[] for(const id of ids)for(const tri of shells.get(id)[kind]){ const ps=tri.map(p=>[p[0]-anchor[0],p[2]-anchor[2],-(p[1]-anchor[1])]),a=ps[1].map((v,i)=>v-ps[0][i]),b=ps[2].map((v,i)=>v-ps[0][i]),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,id+' degenerate shell face');const normal=cross.map(v=>v/n),start=positions.length/3 ps.forEach(p=>{positions.push(...p);normals.push(...normal)});indices.push(start,start+1,start+2) } const builder=new encoderModule.MeshBuilder(),mesh=new encoderModule.Mesh(),encoder=new encoderModule.Encoder(),encoded=new encoderModule.DracoInt8Array() try{ const positionId=builder.AddFloatAttributeToMesh(mesh,encoderModule.POSITION,positions.length/3,3,new Float32Array(positions)),normalId=builder.AddFloatAttributeToMesh(mesh,encoderModule.NORMAL,positions.length/3,3,new Float32Array(normals)) builder.AddFacesToMesh(mesh,indices.length/3,new Uint32Array(indices));encoder.SetAttributeQuantization(encoderModule.POSITION,24);encoder.SetAttributeQuantization(encoderModule.NORMAL,12);encoder.SetSpeedOptions(5,5) const len=encoder.EncodeMeshToDracoBuffer(mesh,encoded);assert.ok(len>0) const bytes=Buffer.from(Array.from({length:len},(_,i)=>encoded.GetValue(i))),pad=(-offset)&3 if(pad){chunks.push(Buffer.alloc(pad));offset+=pad} const vi=views.length;views.push({buffer:0,byteOffset:offset,byteLength:len});chunks.push(bytes);offset+=len // Draco may merge identical corners; decoder determines final accessor count below. const positionAccessor=accessors.length accessors.push({componentType:5126,type:'VEC3',count:0,min:[0,1,2].map(a=>Math.min(...positions.filter((_,i)=>i%3===a))),max:[0,1,2].map(a=>Math.max(...positions.filter((_,i)=>i%3===a)))}) accessors.push({componentType:5126,type:'VEC3',count:0});accessors.push({componentType:5125,type:'SCALAR',count:indices.length}) primitives.push({attributes:{POSITION:positionAccessor,NORMAL:positionAccessor+1},indices:positionAccessor+2,material:kind==='body'?0:1,extensions:{KHR_draco_mesh_compression:{bufferView:vi,attributes:{POSITION:positionId,NORMAL:normalId}}}}) triangles+=indices.length/3 }finally{encoderModule.destroy(encoded);encoderModule.destroy(encoder);encoderModule.destroy(mesh);encoderModule.destroy(builder)} } // Read actual compressed point count rather than assuming source corner counts. let decodedValid=true for(const p of primitives){const ext=p.extensions.KHR_draco_mesh_compression,v=views[ext.bufferView],bin=Buffer.concat(chunks),data=bin.subarray(v.byteOffset,v.byteOffset+v.byteLength),dec=new decoder.Decoder(),db=new decoder.DecoderBuffer(),m=new decoder.Mesh();let status try{ db.Init(new Int8Array(data.buffer,data.byteOffset,data.byteLength),data.byteLength);status=dec.DecodeBufferToMesh(db,m);assert.ok(status.ok()) const n=m.num_points(),f=m.num_faces();accessors[p.attributes.POSITION].count=n;accessors[p.attributes.NORMAL].count=n;vertices+=n function values(semantic){const ptr=decoder._malloc(n*12);try{assert.ok(dec.GetAttributeDataArrayForAllPoints(m,dec.GetAttributeByUniqueId(m,ext.attributes[semantic]),decoder.DT_FLOAT32,n*12,ptr));return new Float32Array(decoder.HEAPF32.buffer,ptr,n*3).slice()}finally{decoder._free(ptr)}} const positions=values('POSITION'),normals=values('NORMAL'),ptr=decoder._malloc(f*12) let indices;try{assert.ok(dec.GetTrianglesUInt32Array(m,f*12,ptr));indices=new Uint32Array(decoder.HEAPU32.buffer,ptr,f*3).slice()}finally{decoder._free(ptr)} for(let i=0;ipositions.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]],length=Math.hypot(...cross) if(length<=1e-10){decodedValid=false;break} for(const j of ids){const normal=normals.slice(j*3,j*3+3),dot=normal.reduce((s,v,k)=>s+v*cross[k],0)/length/Math.hypot(...normal);if(!Number.isFinite(dot)||dot<0.9)decodedValid=false} } }finally{if(status)decoder.destroy(status);decoder.destroy(m);decoder.destroy(db);decoder.destroy(dec)} } if(!decodedValid){keys.forEach(k=>unsafeCells.add(k));ids.forEach(id=>seen.delete(id));continue} assert.ok(triangles<=20000&&vertices<=60000) const glb={asset:{version:'2.0',generator:'Closed surveyed low-cost distant shells'},extensionsUsed:['KHR_draco_mesh_compression'],extensionsRequired:['KHR_draco_mesh_compression'],scene:0,scenes:[{nodes:[0]}],nodes:[{mesh:0,translation:[anchor[0],anchor[2],-anchor[1]]}],meshes:[{primitives}], materials:[{name:'Far_Wall',doubleSided:false,pbrMetallicRoughness:{baseColorFactor:[0.68,0.64,0.55,1],metallicFactor:0,roughnessFactor:0.9}},{name:'Far_Roof',doubleSided:false,pbrMetallicRoughness:{baseColorFactor:[0.26,0.30,0.30,1],metallicFactor:0,roughnessFactor:0.9}}],accessors,bufferViews:views,buffers:[{byteLength:offset}]} const bytes=packGlb(glb,Buffer.concat(chunks)),uri='shell-'+String(index).padStart(3,'0')+'.glb';writeFileSync(join(out,uri),bytes) const radius=Math.hypot(...bounds.min.map((v,a)=>(bounds.max[a]-v)/2)),local=keys.flatMap(k=>{const t=rebase(cellNodes.get(k));return t.content?[t]:t.children}) children.push({boundingVolume:{sphere:[...anchor,radius]},geometricError:192,refine:'REPLACE',viewerRequestVolume:{sphere:[...anchor,radius+14000]},content:{uri},children:local}) records.push({uri,level:'shell',triangles,vertices,bytes:bytes.length,buildingIds:ids,sourceCells:keys,boundsENU:bounds,primitives:2}) } const fallbackCells=[...unsafeCells].map(key=>({cell:key,buildingIds:cellInfo.get(key).buildingIds})) for(const c of fallbackCells){const t=rebase(cellNodes.get(c.cell));t.extras={fallbackSourceCell:c.cell};children.push(t);for(const id of c.buildingIds){assert.ok(!seen.has(id));seen.add(id)}} assert.equal(seen.size,30172) const manifest={buildings:seen.size,fallbackCells,unsafeBuildings,shellFiles:records.length,shellTriangles:records.reduce((s,r)=>s+r.triangles,0),shellVertices:records.reduce((s,r)=>s+r.vertices,0),shellBytes:records.reduce((s,r)=>s+r.bytes,0),shellPrimitives:records.length*2, source:'city-continuous-v8',records:[...records,...oldManifest.records.map(r=>({...r,uri:relative(out,resolve(source,r.uri)).replaceAll('\\','/')}))],decisions:oldManifest.decisions, errors:{shell:192,far:128,near:48},far:oldManifest.far,near:oldManifest.near,fine:oldManifest.fine,closedRoofCaps:true, caveat:'Far-only neutral palette, no windows/interior details; exact GIS ring/top/bottom. Cell/fine geometry unchanged. No FPS/render claim.'} const rootMin=[Infinity,Infinity,Infinity],rootMax=[-Infinity,-Infinity,-Infinity] for(const t of children){const s=t.boundingVolume.sphere;assert.ok(s);for(let a=0;a<3;a++){rootMin[a]=Math.min(rootMin[a],s[a]-s[3]);rootMax[a]=Math.max(rootMax[a],s[a]+s[3])}} const rc=rootMin.map((v,a)=>(v+rootMax[a])/2),rh=rootMin.map((v,a)=>(rootMax[a]-v)/2) writeFileSync(join(out,'tileset.json'),JSON.stringify({...tree,asset:{version:'1.1',generator:'Low-cost far survey shells with continuous local replacement'},root:{...tree.root,boundingVolume:{box:[...rc,rh[0],0,0,0,rh[1],0,0,0,rh[2]]},children}},null,2)) writeFileSync(join(out,'manifest.json'),JSON.stringify(manifest,null,2)) console.log(JSON.stringify({out,buildings:manifest.buildings,shellFiles:manifest.shellFiles,shellTriangles:manifest.shellTriangles,shellBytes:manifest.shellBytes,shellPrimitives:manifest.shellPrimitives}))