| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- // Offline coarse-only instancing candidate. Never edits deployed config or fine assets.
- // Shared exterior wall/window quads + entrance box; survey roof/bottom caps remain unique.
- import assert from 'node:assert/strict'
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
- import { resolve, join } from 'node:path'
- import earcut from 'earcut'
- const root = resolve('tools/city-massing/city/cad-bim')
- const out = join(root, process.argv[2] || 'instanced-coarse-pilot-v1')
- assert.ok(!existsSync(join(out, 'manifest.json')), 'immutable output already exists')
- const plan = JSON.parse(readFileSync(join(root, 'near-estate-pilot-input/seated-plan.json'), 'utf8'))
- const source = JSON.parse(readFileSync(join(root, 'lod-near-estate-pilot-v6/streaming-v14/manifest.json'), 'utf8'))
- const buildingMap = new Map(plan.buildings.map(b => [b.id, b]))
- const tones = ['E4DAC4', 'F2ECDD', 'C2AF96', 'C8C7BD', '596264', '697272', '98634A', 'AC7758', '405F6D', '715747']
- const linear = x => x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
- const materials = tones.map(hex => ({ pbrMetallicRoughness: { baseColorFactor: [0, 2, 4].map(i => linear(parseInt(hex.slice(i, i + 2), 16) / 255)).concat(1), metallicFactor: 0, roughnessFactor: 0.85 }, doubleSided: false }))
- function seed(id) { let v = 2166136261; for (const c of id) v = Math.imul(v ^ c.charCodeAt(0), 16777619) >>> 0; return v }
- function ringArea(p) { return p.reduce((sum, a, i) => { const b = p[(i + 1) % p.length]; return sum + a[0] * b[1] - b[0] * a[1] }, 0) / 2 }
- function cleanRing(p) {
- const ring = []
- for (const point of p) if (!ring.length || Math.hypot(point[0] - ring.at(-1)[0], point[1] - ring.at(-1)[1]) > 0.011) ring.push(point)
- if (ring.length > 1 && Math.hypot(ring[0][0] - ring.at(-1)[0], ring[0][1] - ring.at(-1)[1]) <= 0.011) ring.pop()
- return ringArea(ring) > 0 ? ring : ring.reverse()
- }
- const toGltf = ([x, y, z]) => [x, z, -y]
- const records = [], children = []
- mkdirSync(out, { recursive: true })
- for (const cell of source.cells) {
- const buildings = cell.buildingIds.map(id => { assert.ok(buildingMap.has(id), id); return buildingMap.get(id) })
- const chunks = [], bufferViews = [], accessors = [], meshes = [], nodes = [], groups = new Map()
- let length = 0, renderedTriangles = 0, storedTriangles = 0
- const bounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }
- const points = buildings.flatMap(b => b.footprintENU.map(p => [...p, b.positionENU[2]]))
- const anchor = [0, 1, 2].map(a => (Math.min(...points.map(p => p[a])) + Math.max(...points.map(p => p[a]))) / 2)
- function touch(p) { p.forEach((v, a) => { bounds.min[a] = Math.min(bounds.min[a], v); bounds.max[a] = Math.max(bounds.max[a], v) }) }
- function accessor(values, type, integer = false) {
- const components = { SCALAR: 1, VEC3: 3, VEC4: 4 }[type]
- assert.equal(values.length % components, 0)
- const bytes = Buffer.alloc(values.length * 4)
- values.forEach((v, i) => { assert.ok(Number.isFinite(v)); integer ? bytes.writeUInt32LE(v, i * 4) : bytes.writeFloatLE(v, i * 4) })
- const view = bufferViews.length
- bufferViews.push({ buffer: 0, byteOffset: length, byteLength: bytes.length }); chunks.push(bytes); length += bytes.length
- const count = values.length / components, ranges = {}
- if (!integer && type === 'VEC3') {
- ranges.min = [0, 1, 2].map(a => Math.min(...values.filter((_, i) => i % 3 === a)))
- ranges.max = [0, 1, 2].map(a => Math.max(...values.filter((_, i) => i % 3 === a)))
- }
- accessors.push({ bufferView: view, componentType: integer ? 5125 : 5126, count, type, ...ranges })
- return accessors.length - 1
- }
- const quad = { positions: [-0.5,-0.5,0, 0.5,-0.5,0, 0.5,0.5,0, -0.5,0.5,0], normals: [0,0,1, 0,0,1, 0,0,1, 0,0,1], indices: [0,1,2,0,2,3] }
- const cube = { positions: [], normals: [], indices: [] }
- for (const [normal, u, v] of [
- [[1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,-1,0],[0,0,1]],
- [[0,1,0],[0,0,1],[1,0,0]], [[0,-1,0],[0,0,-1],[1,0,0]],
- [[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]],
- ]) {
- const start = cube.positions.length / 3
- for (const [s,t] of [[-1,-1],[1,-1],[1,1],[-1,1]]) {
- cube.positions.push(...normal.map((x,a) => (x + s*u[a] + t*v[a]) / 2)); cube.normals.push(...normal)
- }
- cube.indices.push(start,start+1,start+2,start,start+2,start+3)
- }
- const geometry = shape => ({ attributes: { POSITION: accessor(shape.positions, 'VEC3'), NORMAL: accessor(shape.normals, 'VEC3') }, indices: accessor(shape.indices, 'SCALAR', true) })
- const quadPrimitive = geometry(quad), cubePrimitive = geometry(cube)
- function instance(kind, material, translationENU, rotation, scale, id) {
- assert.ok(scale.every(v => v > 0))
- const key = `${kind}/${material}`
- if (!groups.has(key)) groups.set(key, { kind, material, translations: [], rotations: [], scales: [], buildingIds: [] })
- const group = groups.get(key)
- group.translations.push(...toGltf(translationENU.map((v,a) => v - anchor[a])))
- group.rotations.push(0, Math.sin(rotation / 2), 0, Math.cos(rotation / 2))
- group.scales.push(...scale); group.buildingIds.push(id)
- renderedTriangles += kind === 'quad' ? 2 : 12
- }
- const caps = new Map()
- function triangle(material, ps, normal) {
- if (!caps.has(material)) caps.set(material, { positions: [], normals: [], indices: [] })
- const cap = caps.get(material), start = cap.positions.length / 3
- for (const p of ps) { touch(p); cap.positions.push(...toGltf(p.map((v,a) => v - anchor[a]))); cap.normals.push(...normal) }
- cap.indices.push(start,start+1,start+2); renderedTriangles++
- }
- for (const b of buildings) {
- const ring = cleanRing(b.footprintENU), bottom = b.foundationBottom, top = b.positionENU[2] + b.heightM
- const s = seed(b.id), wall = s % 4, roof = 4 + Math.floor(s / 4) % 4
- assert.ok(top > bottom && ringArea(ring) > 0)
- for (const p of ring) { touch([...p, bottom]); touch([...p, top]) }
- const capIndices = earcut(ring.flat())
- let covered = 0
- for (let i = 0; i < capIndices.length; i += 3) {
- const ps = capIndices.slice(i, i+3).map(j => ring[j])
- const area = ringArea(ps)
- if (area < 0) ps.reverse()
- covered += Math.abs(area)
- triangle(roof, ps.map(p => [...p, top]), [0,1,0])
- triangle(wall, ps.toReversed().map(p => [...p, bottom]), [0,-1,0])
- }
- const perimeter = ring.reduce((sum,p,i) => sum + Math.hypot(p[0]-ring[(i+1)%ring.length][0],p[1]-ring[(i+1)%ring.length][1]),0)
- assert.ok(Math.abs(covered - ringArea(ring)) <= Math.max(0.001, perimeter * 0.01))
- let longest = null
- const floors = Math.max(1, b.floors || Math.round(b.heightM/3.2)), floorHeight = Math.max(2.4,(top-b.positionENU[2])/floors)
- for (let i=0; i<ring.length; i++) {
- const a=ring[i], z=ring[(i+1)%ring.length], dx=z[0]-a[0], dy=z[1]-a[1], edge=Math.hypot(dx,dy), angle=Math.atan2(dy,dx)
- if (edge <= 0) continue
- const center=[(a[0]+z[0])/2,(a[1]+z[1])/2]
- instance('quad',wall,[...center,(bottom+top)/2],angle,[edge,top-bottom,1],b.id)
- if (!longest || edge > longest.edge) longest={edge,angle,center,dx,dy}
- if (edge < 0.35) continue
- for (let floor=0; floor<floors; floor++) {
- const z0=b.positionENU[2]+floor*floorHeight+floorHeight*0.34, z1=Math.min(top-0.08,z0+floorHeight*0.36)
- if (z1 <= z0) continue
- const half=Math.min(edge*0.68*0.22, Math.max(0.45,edge*0.68/3.5*0.42))
- instance('quad',8,[center[0]+dy/edge*0.035,center[1]-dx/edge*0.035,(z0+z1)/2],angle,[half*2,z1-z0,1],b.id)
- }
- }
- if (longest?.edge >= 0.35) {
- const {edge,angle,center,dx,dy}=longest, base=b.positionENU[2]+0.08
- const end=Math.min(top-0.05,b.positionENU[2]+Math.min(2.2,Math.max(1.8,floorHeight*0.76)))
- if (end>base) instance('cube',9,[center[0]+dy/edge*0.1475,center[1]-dx/edge*0.1475,(base+end)/2],angle,[Math.min(edge,3.5)*0.44,end-base,0.095],b.id)
- }
- }
- for (const group of groups.values()) {
- const primitive = group.kind === 'quad' ? quadPrimitive : cubePrimitive
- meshes.push({ primitives: [{ ...primitive, material: group.material }] })
- nodes.push({ mesh: meshes.length-1, extensions: { EXT_mesh_gpu_instancing: { attributes: {
- TRANSLATION: accessor(group.translations,'VEC3'), ROTATION: accessor(group.rotations,'VEC4'), SCALE: accessor(group.scales,'VEC3'),
- } } }, extras: { buildingIds: group.buildingIds } })
- storedTriangles += group.kind === 'quad' ? 2 : 12
- }
- for (const [material, cap] of caps) {
- meshes.push({ primitives: [{ ...geometry(cap), material }] }); nodes.push({ mesh: meshes.length-1 }); storedTriangles += cap.indices.length/3
- }
- // Conservative detail margin, not a change to survey outline or seating.
- bounds.min = bounds.min.map(v=>v-0.3); bounds.max=bounds.max.map(v=>v+0.3)
- const center=bounds.min.map((v,a)=>(v+bounds.max[a])/2), half=bounds.min.map((v,a)=>(bounds.max[a]-v)/2)
- const json={ asset:{version:'2.0',generator:'Surveyed coarse exterior instancing pilot'}, extensionsUsed:['EXT_mesh_gpu_instancing'], extensionsRequired:['EXT_mesh_gpu_instancing'],
- scene:0, scenes:[{nodes:[0]}], nodes:[{translation:toGltf(anchor),children:nodes.map((_,i)=>i+1)},...nodes], meshes,materials,accessors,bufferViews,buffers:[{byteLength:length}] }
- let jsonBytes=Buffer.from(JSON.stringify(json)); jsonBytes=Buffer.concat([jsonBytes,Buffer.alloc((-jsonBytes.length)&3,0x20)])
- const bin=Buffer.concat(chunks), header=Buffer.alloc(20), binHeader=Buffer.alloc(8)
- header.write('glTF');header.writeUInt32LE(2,4);header.writeUInt32LE(28+jsonBytes.length+bin.length,8);header.writeUInt32LE(jsonBytes.length,12);header.write('JSON',16)
- binHeader.writeUInt32LE(bin.length);binHeader.write('BIN\0',4)
- const bytes=Buffer.concat([header,jsonBytes,binHeader,bin]),uri=`${cell.id}-instanced-lod1.glb`
- writeFileSync(join(out,uri),bytes)
- records.push({uri,buildingIds:cell.buildingIds,bytes:bytes.length,storedTriangles,renderedTriangles,instanceCount:[...groups.values()].reduce((sum,g)=>sum+g.buildingIds.length,0),boundsENU:bounds})
- children.push({boundingVolume:{box:[...center,half[0],0,0,0,half[1],0,0,0,half[2]]},geometricError:0,
- viewerRequestVolume:{sphere:[...center,Math.hypot(...half)+5000]},content:{uri}})
- }
- writeFileSync(join(out,'tileset.json'),JSON.stringify({asset:{version:'1.1',generator:'Instanced coarse pilot only'},geometricError:1000000,
- root:{transform:plan.transform,boundingVolume:JSON.parse(readFileSync(join(root,'lod-near-estate-pilot-v6/streaming-v14/tileset.json'),'utf8')).root.boundingVolume,geometricError:1000000,refine:'REPLACE',children}},null,2))
- writeFileSync(join(out,'manifest.json'),JSON.stringify({candidateOnly:true,buildings:records.flatMap(r=>r.buildingIds).length,records,
- caveat:'True GPU-instanced coarse exterior pilot. No fine LOD replacement or runtime/FPS validation; not configured. Windows/doors are procedural estimates, not surveyed facade detail.'},null,2))
- console.log(JSON.stringify({output:out,buildings:records.flatMap(r=>r.buildingIds).length,files:records.length,bytes:records.reduce((s,r)=>s+r.bytes,0),storedTriangles:records.reduce((s,r)=>s+r.storedTriangles,0),renderedTriangles:records.reduce((s,r)=>s+r.renderedTriangles,0)}))
|