// 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 longest.edge) longest={edge,angle,center,dx,dy} if (edge < 0.35) continue for (let floor=0; floor= 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)}))