build-continuous-city-tileset.mjs 4.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
  1. // One REPLACE hierarchy owns far/near coverage. No global independent-tileset handoff.
  2. // Reuses immutable bounded source GLBs; no citywide fine geometry generation.
  3. import assert from 'node:assert/strict'
  4. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  5. import { resolve, join, relative } from 'node:path'
  6. const root = resolve('tools/city-massing/city/cad-bim')
  7. const output = join(root, process.argv[2] || 'city-continuous-v5')
  8. assert.ok(!existsSync(join(output, 'tileset.json')), 'use a new deployment generation')
  9. const batches = ['pilot', 'batch-urban8000-dedup', 'batch-urban8000b', 'batch-urban8000c',
  10. 'batch-urban-final', 'batch-urban-tail', 'batch-unfitted-fallback'].map((name) => `lod-${name}-roofed2/streaming-v5`)
  11. // Keep complete coarse coverage and add the surveyed near-estate detail as a
  12. // separate spatial branch; the duplicate-ID guard below prevents overlap.
  13. batches.push('lod-near-estate-pilot-v6/streaming-v14')
  14. if (process.argv[3]) batches.push(process.argv[3])
  15. const bounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }
  16. const ids = new Set(), children = [], sources = []
  17. let transform
  18. const sphere = (box) => [...box.slice(0, 3), Math.hypot(box[3], box[7], box[11])]
  19. for (const source of batches) {
  20. const dir = join(root, source)
  21. const tree = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
  22. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  23. transform ||= tree.root.transform
  24. assert.deepEqual(tree.root.transform, transform)
  25. assert.equal(manifest.provenance.closedRoofCaps, true)
  26. for (const id of manifest.cells.flatMap((c) => c.buildingIds)) {
  27. assert.ok(!ids.has(id), `duplicate surveyed building ${id}`)
  28. ids.add(id)
  29. }
  30. function adapt(tile) {
  31. const clone = structuredClone(tile)
  32. if (tile.content?.uri) clone.content.uri = relative(output, join(dir, tile.content.uri)).replaceAll('\\', '/')
  33. if (tile.children?.length) {
  34. // Bounding spheres explicitly disable Cesium's childrenWithinParent box optimization.
  35. // Otherwise a child outside its narrow request volume can incorrectly cull its coarse parent.
  36. clone.boundingVolume = { sphere: sphere(tile.boundingVolume.box) }
  37. clone.geometricError = 1000000 // traversal sentinel, not a measured survey error
  38. clone.children = tile.children.map(adapt)
  39. }
  40. // Keep each coarse content request sphere. Only structural parents and fine GE0
  41. // tiles may omit it; this lets Cesium retire far coarse partitions by distance while
  42. // retaining local coarse coverage during fine refinement.
  43. if (tile.children?.length && !/-lod1\.glb$/.test(tile.content?.uri || '')) delete clone.viewerRequestVolume
  44. return clone
  45. }
  46. children.push(...tree.root.children.map(adapt))
  47. const box = tree.root.boundingVolume.box
  48. for (let axis = 0; axis < 3; axis++) {
  49. bounds.min[axis] = Math.min(bounds.min[axis], box[axis] - box[[3, 7, 11][axis]])
  50. bounds.max[axis] = Math.max(bounds.max[axis], box[axis] + box[[3, 7, 11][axis]])
  51. }
  52. sources.push({ source, count: manifest.count, files: manifest.files.length })
  53. }
  54. const center = bounds.min.map((v, i) => (v + bounds.max[i]) / 2)
  55. const half = bounds.min.map((v, i) => (bounds.max[i] - v) / 2)
  56. const box = [...center, half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  57. mkdirSync(output, { recursive: true })
  58. const anchor = join(root, batches[0], 'root-anchor.glb')
  59. writeFileSync(join(output, 'root-anchor.glb'), readFileSync(anchor))
  60. writeFileSync(join(output, 'tileset.json'), JSON.stringify({ asset: { version: '1.1', generator: 'Continuous surveyed city distance-first REPLACE' },
  61. geometricError: 1000000, root: { transform, boundingVolume: { box }, geometricError: 1000000,
  62. refine: 'REPLACE', content: { uri: 'root-anchor.glb' }, children } }, null, 2))
  63. writeFileSync(join(output, 'manifest.json'), JSON.stringify({ buildings: ids.size, sources, boundsENU: bounds,
  64. policy: 'Continuous bounded LOD1; coarse and fine request spheres are retained for distance-based progressive loading. Fine GE0 uses half-diagonal +250m; coarse uses its source request sphere. Same REPLACE hierarchy; local coarse coverage remains during refinement.',
  65. closedRoofCaps: true, heightBasedLOD: false }, null, 2))
  66. console.log(JSON.stringify({ output, buildings: ids.size, sources }))