build-streaming-tiles.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. // Convert existing merged cells into small spatial GLBs. Source assets remain untouched.
  2. // node tools/city-massing/build-streaming-tiles.mjs <batch-dir> [--max-triangles 20000]
  3. import assert from 'node:assert/strict'
  4. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  5. import { join, resolve } from 'node:path'
  6. import { createHash } from 'node:crypto'
  7. import draco3d from 'draco3d'
  8. import { rasterLimits, tileRasterBudget } from './raster-budget.mjs'
  9. const args = process.argv.slice(2)
  10. const option = (key, fallback) => { const index = args.indexOf(key); return index < 0 ? fallback : Number(args.splice(index, 2)[1]) }
  11. const maxTriangles = option('--max-triangles', 20000)
  12. const coarseDistance = option('--coarse-distance', 1500)
  13. const detailDistance = option('--detail-distance', 250)
  14. // Distance-first refinement retains local request spheres; effectiveness depends on Cesium traversal mode.
  15. const distanceFirst = args.includes('--distance-first')
  16. if (distanceFirst) args.splice(args.indexOf('--distance-first'), 1)
  17. const generationIndex = args.indexOf('--generation')
  18. const generation = generationIndex < 0 ? 'streaming-v2' : args.splice(generationIndex, 2)[1]
  19. assert.match(generation, /^streaming-v\d+$/)
  20. assert.ok(Number.isInteger(maxTriangles) && maxTriangles > 0 && maxTriangles <= 20000)
  21. assert.ok(coarseDistance > detailDistance && detailDistance > 0)
  22. const batch = resolve(args[0])
  23. const out = join(batch, generation)
  24. // Never regenerate a directory currently served by the app. A new generation gets a new URL.
  25. assert.ok(!existsSync(join(out, 'manifest.json')), `output already exists: ${out}`)
  26. mkdirSync(out, { recursive: true })
  27. const manifest = JSON.parse(readFileSync(join(batch, 'manifest.json'), 'utf8'))
  28. const originalTree = JSON.parse(readFileSync(join(batch, 'tileset.json'), 'utf8'))
  29. const draco = await draco3d.createDecoderModule({})
  30. const encoderModule = await draco3d.createEncoderModule({})
  31. const report = { generation, sourceBatch: batch, count: manifest.count, encoding: 'draco-small-tiles',
  32. provenance: manifest.provenance || { gisFootprints: false, roadsProcedural: null, facadeDetails: null },
  33. budget: { maxTriangles, maxVertices: maxTriangles * 3, ...rasterLimits, coarseDistance, detailDistance },
  34. origin: manifest.origin, cells: [], files: [], totals: { lod1: { triangles: 0, bytes: 0 }, lod0: { triangles: 0, bytes: 0 } } }
  35. function parseGlb(path) {
  36. const bytes = readFileSync(path)
  37. assert.equal(bytes.toString('ascii', 0, 4), 'glTF')
  38. assert.equal(bytes.readUInt32LE(8), bytes.length)
  39. const length = bytes.readUInt32LE(12)
  40. const json = JSON.parse(bytes.toString('utf8', 20, 20 + length))
  41. const offset = length + 20
  42. assert.equal(bytes.toString('ascii', offset + 4, offset + 8), 'BIN\0')
  43. return { bytes, json, bin: bytes.subarray(offset + 8) }
  44. }
  45. function decode(path) {
  46. const { bytes, json, bin } = parseGlb(path)
  47. assert.ok(!json.skins?.length, 'skinned meshes are not supported')
  48. // Preserve embedded raster bytes; export remaps each partition's dependency indices.
  49. const images = (json.images || []).map((image) => {
  50. assert.ok(image.bufferView !== undefined && !image.uri, 'images must be embedded in the source GLB')
  51. assert.ok(['image/png', 'image/jpeg'].includes(image.mimeType), 'unsupported raster image')
  52. const view = json.bufferViews[image.bufferView]
  53. return { mimeType: image.mimeType, name: image.name,
  54. bytes: bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength) }
  55. })
  56. const nodes = json.nodes.filter((node) => node.mesh !== undefined)
  57. assert.equal(nodes.length, 1)
  58. assert.ok(!nodes[0].matrix && !nodes[0].translation && !nodes[0].scale && !nodes[0].rotation,
  59. 'source node transforms must already be baked')
  60. const primitives = []
  61. for (const primitive of json.meshes[nodes[0].mesh].primitives) {
  62. assert.equal(primitive.mode ?? 4, 4)
  63. const extension = primitive.extensions.KHR_draco_mesh_compression
  64. const view = json.bufferViews[extension.bufferView]
  65. const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
  66. const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
  67. let status
  68. try {
  69. buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
  70. status = decoder.DecodeBufferToMesh(buffer, mesh)
  71. assert.ok(status.ok(), status.error_msg())
  72. const attributes = {}
  73. for (const [semantic, uniqueId] of Object.entries(extension.attributes)) {
  74. assert.ok(['POSITION', 'NORMAL', 'TEXCOORD_0'].includes(semantic), `unsupported source attribute ${semantic}`)
  75. const attribute = decoder.GetAttributeByUniqueId(mesh, uniqueId)
  76. const components = semantic === 'TEXCOORD_0' ? 2 : 3
  77. assert.equal(attribute.num_components(), components)
  78. const size = mesh.num_points() * components * 4
  79. const ptr = draco._malloc(size)
  80. try {
  81. decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, size, ptr)
  82. attributes[semantic] = new Float32Array(draco.HEAPF32.buffer, ptr, mesh.num_points() * components).slice()
  83. } finally { draco._free(ptr) }
  84. }
  85. const size = mesh.num_faces() * 3 * 4
  86. const ptr = draco._malloc(size)
  87. let indices
  88. try {
  89. decoder.GetTrianglesUInt32Array(mesh, size, ptr)
  90. indices = new Uint32Array(draco.HEAPU32.buffer, ptr, mesh.num_faces() * 3).slice()
  91. } finally { draco._free(ptr) }
  92. primitives.push({ material: primitive.material, attributes, indices })
  93. } finally {
  94. if (status) draco.destroy(status)
  95. draco.destroy(mesh); draco.destroy(buffer); draco.destroy(decoder)
  96. }
  97. }
  98. const triangleCount = primitives.reduce((sum, primitive) => sum + primitive.indices.length / 3, 0)
  99. const primitiveIndex = new Uint16Array(triangleCount), faceIndex = new Uint32Array(triangleCount)
  100. const centers = new Float32Array(triangleCount * 3)
  101. let current = 0
  102. primitives.forEach((primitive, pi) => {
  103. const positions = primitive.attributes.POSITION
  104. for (let face = 0; face < primitive.indices.length / 3; face++, current++) {
  105. primitiveIndex[current] = pi; faceIndex[current] = face
  106. for (let axis = 0; axis < 3; axis++) {
  107. let value = 0
  108. for (let corner = 0; corner < 3; corner++) value += positions[primitive.indices[face * 3 + corner] * 3 + axis] / 3
  109. centers[current * 3 + axis] = value
  110. }
  111. }
  112. })
  113. return { primitives, materials: json.materials, images, textures: json.textures, samplers: json.samplers,
  114. extensionsUsed: (json.extensionsUsed || []).filter((x) => x !== 'KHR_draco_mesh_compression'),
  115. primitiveIndex, faceIndex, centers, ids: Uint32Array.from({ length: triangleCount }, (_, index) => index), triangleCount,
  116. sourceHash: createHash('sha256').update(bytes).digest('hex') }
  117. }
  118. function subsetBounds(levels, selections) {
  119. const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
  120. for (let level = 0; level < 2; level++) {
  121. const data = levels[level]
  122. for (const id of selections[level]) {
  123. const primitive = data.primitives[data.primitiveIndex[id]], positions = primitive.attributes.POSITION
  124. const face = data.faceIndex[id]
  125. for (let corner = 0; corner < 3; corner++) {
  126. const index = primitive.indices[face * 3 + corner]
  127. // glTF Y-up -> source ENU. The root retains the original ECEF transform.
  128. const enu = [positions[index * 3], -positions[index * 3 + 2], positions[index * 3 + 1]]
  129. for (let axis = 0; axis < 3; axis++) { min[axis] = Math.min(min[axis], enu[axis]); max[axis] = Math.max(max[axis], enu[axis]) }
  130. }
  131. }
  132. }
  133. return { min, max }
  134. }
  135. function partition(levels, selections, leaves) {
  136. if (Math.max(...selections.map((ids) => ids.length)) <= maxTriangles) { leaves.push(selections); return }
  137. const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
  138. levels.forEach((data, level) => {
  139. for (const id of selections[level]) for (let axis = 0; axis < 3; axis++) {
  140. const value = data.centers[id * 3 + axis]
  141. min[axis] = Math.min(min[axis], value); max[axis] = Math.max(max[axis], value)
  142. }
  143. })
  144. const spans = max.map((value, axis) => value - min[axis])
  145. const axis = spans.indexOf(Math.max(...spans)), midpoint = (min[axis] + max[axis]) / 2
  146. const left = [], right = []
  147. levels.forEach((data, level) => {
  148. const ids = selections[level], a = [], b = []
  149. for (const id of ids) (data.centers[id * 3 + axis] < midpoint ? a : b).push(id)
  150. left.push(Uint32Array.from(a)); right.push(Uint32Array.from(b))
  151. })
  152. // Coincident geometry still needs a bounded upload; split deterministically by source index.
  153. if (left.every((ids) => !ids.length) || right.every((ids) => !ids.length)) {
  154. selections.forEach((ids, level) => { left[level] = ids.slice(0, Math.ceil(ids.length / 2)); right[level] = ids.slice(Math.ceil(ids.length / 2)) })
  155. }
  156. partition(levels, left, leaves); partition(levels, right, leaves)
  157. }
  158. function box(bounds) {
  159. const center = bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2)
  160. const half = bounds.min.map((value, axis) => Math.max(0.001, (bounds.max[axis] - value) / 2))
  161. return [center[0], center[1], center[2], half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  162. }
  163. function requestVolume(bounds, distance) {
  164. const center = bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2)
  165. const radius = Math.hypot(...bounds.min.map((value, axis) => (bounds.max[axis] - value) / 2))
  166. return { sphere: [...center, radius + distance] }
  167. }
  168. function exportGlb(name, data, ids, bounds, level) {
  169. const center = bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2)
  170. const gltfCenter = [center[0], center[2], -center[1]]
  171. const chunks = [], views = [], accessors = [], primitives = []
  172. let byteOffset = 0, vertexCount = 0
  173. function appendPayload(bytes) {
  174. const viewIndex = views.length
  175. views.push({ buffer: 0, byteOffset, byteLength: bytes.length })
  176. chunks.push(bytes); byteOffset += bytes.length
  177. if (byteOffset % 4) { const padding = Buffer.alloc(4 - byteOffset % 4); chunks.push(padding); byteOffset += padding.length }
  178. return viewIndex
  179. }
  180. function accessor(type, count, components, range = {}) {
  181. const index = accessors.length
  182. accessors.push({ componentType: type, count, type: components === 1 ? 'SCALAR' : components === 2 ? 'VEC2' : 'VEC3', ...range })
  183. return index
  184. }
  185. const byPrimitive = new Map()
  186. for (const id of ids) {
  187. const pi = data.primitiveIndex[id]
  188. if (!byPrimitive.has(pi)) byPrimitive.set(pi, [])
  189. byPrimitive.get(pi).push(data.faceIndex[id])
  190. }
  191. for (const [pi, faces] of byPrimitive) {
  192. const source = data.primitives[pi], used = new Map(), oldIndices = [], indices = []
  193. for (const face of faces) for (let corner = 0; corner < 3; corner++) {
  194. const index = source.indices[face * 3 + corner]
  195. if (!used.has(index)) { used.set(index, oldIndices.length); oldIndices.push(index) }
  196. indices.push(used.get(index))
  197. }
  198. const builder = new encoderModule.MeshBuilder(), mesh = new encoderModule.Mesh()
  199. const encoder = new encoderModule.Encoder(), encoded = new encoderModule.DracoInt8Array()
  200. try {
  201. builder.AddFacesToMesh(mesh, indices.length / 3, Uint32Array.from(indices))
  202. const attributeIds = {}, ranges = {}
  203. for (const [semantic, values] of Object.entries(source.attributes)) {
  204. const components = semantic === 'TEXCOORD_0' ? 2 : 3
  205. const result = new Float32Array(oldIndices.length * components), min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]
  206. oldIndices.forEach((index, output) => {
  207. for (let axis = 0; axis < components; axis++) {
  208. const value = values[index * components + axis] - (semantic === 'POSITION' ? gltfCenter[axis] : 0)
  209. result[output * components + axis] = value
  210. min[axis] = Math.min(min[axis], value); max[axis] = Math.max(max[axis], value)
  211. }
  212. })
  213. attributeIds[semantic] = builder.AddFloatAttributeToMesh(mesh,
  214. encoderModule[semantic === 'TEXCOORD_0' ? 'TEX_COORD' : semantic], oldIndices.length, components, result)
  215. ranges[semantic] = semantic === 'POSITION' ? { min, max } : {}
  216. }
  217. // Prefer cheap decode over maximum compression. Tiles are tiny, and have local origins.
  218. encoder.SetSpeedOptions(7, 10)
  219. encoder.SetEncodingMethod(encoderModule.MESH_SEQUENTIAL_ENCODING)
  220. // Near architectural roofs need extra precision to retain surveyed near-collinear slivers.
  221. encoder.SetAttributeQuantization(encoderModule.POSITION, manifest.provenance?.nearArchitecture ? 20 : 16)
  222. encoder.SetAttributeQuantization(encoderModule.NORMAL, 12)
  223. encoder.SetAttributeQuantization(encoderModule.TEX_COORD, 14)
  224. encoder.SetTrackEncodedProperties(true)
  225. const length = encoder.EncodeMeshToDracoBuffer(mesh, encoded)
  226. assert.ok(length > 0, `${name}: Draco encoding failed`)
  227. const points = encoder.GetNumberOfEncodedPoints(), faces = encoder.GetNumberOfEncodedFaces()
  228. assert.equal(faces, indices.length / 3, `${name}: compression changed triangle count`)
  229. vertexCount += points
  230. const bytes = Buffer.alloc(length)
  231. for (let index = 0; index < length; index++) bytes[index] = encoded.GetValue(index) & 255
  232. const attributes = Object.fromEntries(Object.keys(attributeIds).map((semantic) => [semantic, accessor(5126, points, semantic === 'TEXCOORD_0' ? 2 : 3, ranges[semantic])]))
  233. primitives.push({ attributes, indices: accessor(points < 65536 ? 5123 : 5125, faces * 3, 1),
  234. material: source.material, mode: 4,
  235. extensions: { KHR_draco_mesh_compression: { bufferView: appendPayload(bytes), attributes: attributeIds } } })
  236. } finally {
  237. encoderModule.destroy(encoded); encoderModule.destroy(encoder); encoderModule.destroy(mesh); encoderModule.destroy(builder)
  238. }
  239. }
  240. assert.ok(vertexCount <= maxTriangles * 3, `${name}: vertex budget`)
  241. // Keep only this partition's dependency closure; no unused cell-wide roof rasters.
  242. const materialIds = new Map(), textureIds = new Map(), imageIds = new Map(), samplerIds = new Map()
  243. const materials = [], textures = [], images = [], samplers = []
  244. const rasterImages = []
  245. function copyImage(index) {
  246. if (!imageIds.has(index)) {
  247. const { bytes, ...image } = data.images[index]
  248. rasterImages.push({ bytes, mimeType: image.mimeType })
  249. imageIds.set(index, images.length)
  250. images.push({ ...image, bufferView: appendPayload(bytes) })
  251. }
  252. return imageIds.get(index)
  253. }
  254. function copyTexture(index) {
  255. if (!textureIds.has(index)) {
  256. const texture = structuredClone(data.textures[index])
  257. assert.ok(!texture.extensions, 'texture extensions require explicit dependency remapping')
  258. texture.source = copyImage(texture.source)
  259. if (texture.sampler !== undefined) {
  260. const original = texture.sampler
  261. if (!samplerIds.has(original)) {
  262. samplerIds.set(original, samplers.length)
  263. samplers.push(structuredClone(data.samplers[original]))
  264. }
  265. texture.sampler = samplerIds.get(original)
  266. }
  267. textureIds.set(index, textures.length)
  268. textures.push(texture)
  269. }
  270. return textureIds.get(index)
  271. }
  272. function remapTextureInfo(value) {
  273. for (const [key, child] of Object.entries(value)) {
  274. if (!child || typeof child !== 'object') continue
  275. if (key.endsWith('Texture') && Number.isInteger(child.index)) child.index = copyTexture(child.index)
  276. else remapTextureInfo(child)
  277. }
  278. }
  279. for (const primitive of primitives) {
  280. const original = primitive.material
  281. if (!materialIds.has(original)) {
  282. const material = structuredClone(data.materials[original])
  283. remapTextureInfo(material)
  284. materialIds.set(original, materials.length)
  285. materials.push(material)
  286. }
  287. primitive.material = materialIds.get(original)
  288. }
  289. const rasterBudget = tileRasterBudget(rasterImages, textures)
  290. const payload = Buffer.concat(chunks)
  291. const bin = Buffer.concat([payload, Buffer.alloc((-payload.length) & 3)])
  292. const json = { asset: { version: '2.0', generator: 'CAD bounded spatial streaming' }, scene: 0,
  293. scenes: [{ nodes: [0] }], nodes: [{ mesh: 0, translation: gltfCenter, name }], meshes: [{ primitives }],
  294. materials, images: images.length ? images : undefined, textures: textures.length ? textures : undefined,
  295. samplers: samplers.length ? samplers : undefined, accessors, bufferViews: views, buffers: [{ byteLength: bin.length }],
  296. extensionsUsed: [...data.extensionsUsed, 'KHR_draco_mesh_compression'], extensionsRequired: ['KHR_draco_mesh_compression'] }
  297. let jsonBytes = Buffer.from(JSON.stringify(json))
  298. jsonBytes = Buffer.concat([jsonBytes, Buffer.alloc((4 - jsonBytes.length % 4) % 4, 0x20)])
  299. const header = Buffer.alloc(20), binHeader = Buffer.alloc(8)
  300. header.write('glTF'); header.writeUInt32LE(2, 4); header.writeUInt32LE(28 + jsonBytes.length + bin.length, 8)
  301. header.writeUInt32LE(jsonBytes.length, 12); header.write('JSON', 16)
  302. binHeader.writeUInt32LE(bin.length, 0); binHeader.write('BIN\0', 4)
  303. const bytes = Buffer.concat([header, jsonBytes, binHeader, bin])
  304. writeFileSync(join(out, name), bytes)
  305. const record = { uri: name, level, triangles: ids.length, vertices: vertexCount, bytes: bytes.length,
  306. boundsENU: bounds, materials: primitives.length, ...rasterBudget }
  307. report.files.push(record)
  308. report.totals[level].triangles += ids.length; report.totals[level].bytes += bytes.length
  309. return record
  310. }
  311. const rootChildren = []
  312. for (const cell of manifest.cells) {
  313. const levels = [decode(join(batch, cell.lod1.uri)), decode(join(batch, cell.lod0.uri))]
  314. assert.equal(levels[0].triangleCount, cell.lod1.triangles)
  315. assert.equal(levels[1].triangleCount, cell.lod0.triangles)
  316. const leaves = []
  317. partition(levels, levels.map((data) => data.ids), leaves)
  318. const children = []
  319. leaves.forEach((selections, index) => {
  320. const bounds = subsetBounds(levels, selections), volume = { box: box(bounds) }
  321. const records = selections.map((ids, level) => ids.length
  322. ? exportGlb(`${cell.id}-p${String(index).padStart(3, '0')}-lod${1 - level}.glb`, levels[level], ids, bounds, level ? 'lod0' : 'lod1') : null)
  323. const fine = records[1] ? { boundingVolume: volume, geometricError: 0,
  324. viewerRequestVolume: requestVolume(bounds, detailDistance), content: { uri: records[1].uri } } : null
  325. if (records[0]) children.push({ boundingVolume: volume, geometricError: fine ? (distanceFirst ? 1000000 : 16) : 0, refine: 'REPLACE',
  326. viewerRequestVolume: requestVolume(bounds, coarseDistance), content: { uri: records[0].uri }, ...(fine ? { children: [fine] } : {}) })
  327. else if (fine) children.push(fine)
  328. })
  329. // Empty structural parents do not load a huge GLB. Only their small visible children stream.
  330. rootChildren.push({ boundingVolume: { box: box(cell.boundsENU) }, geometricError: 1000000, refine: 'REPLACE', children })
  331. report.cells.push({ id: cell.id, buildingIds: cell.buildingIds, partitions: leaves.length,
  332. source: levels.map((data, index) => ({ level: index ? 'lod0' : 'lod1', triangles: data.triangleCount, sha256: data.sourceHash })) })
  333. console.log(`${cell.id}: ${cell.count} buildings, ${leaves.length} bounded partitions`)
  334. }
  335. const anchor = readFileSync(join(batch, 'root-anchor.glb'))
  336. writeFileSync(join(out, 'root-anchor.glb'), anchor)
  337. const tileset = { asset: { version: '1.1', generator: 'CAD bounded spatial streaming' }, geometricError: 1000000,
  338. root: { transform: originalTree.root.transform, boundingVolume: originalTree.root.boundingVolume, geometricError: 1000000,
  339. refine: 'REPLACE', content: { uri: 'root-anchor.glb' }, children: rootChildren } }
  340. writeFileSync(join(out, 'tileset.json'), JSON.stringify(tileset, null, 2) + '\n')
  341. writeFileSync(join(out, 'manifest.json'), JSON.stringify(report, null, 2) + '\n')
  342. console.log(JSON.stringify({ output: out, buildings: report.count, files: report.files.length,
  343. maxVertices: Math.max(...report.files.map((file) => file.vertices)), totals: report.totals }))