| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113 |
- // 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;index<groups.length;index++){
- const keys=groups[index],ids=keys.flatMap(k=>cellInfo.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;i<indices.length;i+=3){const ids=indices.slice(i,i+3),ps=[...ids].map(j=>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]],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}))
|