build-hybrid-city-tileset.mjs 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. // Hybrid runtime tree: keep the proven 74-file far coarse layer and add only local fine leaves.
  2. // Fine leaves are additive siblings so a nearby detail request never removes distant coarse coverage.
  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 outputName = process.argv[2] || 'city-hybrid-v1'
  8. const output = join(root, outputName)
  9. assert.ok(!existsSync(join(output, 'tileset.json')), 'use a new deployment generation')
  10. const farDir = join(root, 'far-roofed2')
  11. const fineDir = join(root, 'city-continuous-v5')
  12. const farTree = JSON.parse(readFileSync(join(farDir, 'tileset.json'), 'utf8'))
  13. const fineTree = JSON.parse(readFileSync(join(fineDir, 'tileset.json'), 'utf8'))
  14. assert.deepEqual(farTree.root.transform, fineTree.root.transform)
  15. function adaptFar(tile) {
  16. const clone = structuredClone(tile)
  17. if (tile.content?.uri) clone.content.uri = relative(output, join(farDir, tile.content.uri)).replaceAll('\\', '/')
  18. delete clone.id
  19. delete clone.children
  20. // The far wrapper has one bounded coarse content per child. Keep that content
  21. // as an independent sibling so local fine leaves cannot hide other coarse areas.
  22. return clone
  23. }
  24. function collectFineLeaves(tile, leaves = []) {
  25. if (tile.content?.uri && /-lod0\.glb$/.test(tile.content.uri)) {
  26. const clone = structuredClone(tile)
  27. clone.content.uri = relative(output, join(fineDir, tile.content.uri)).replaceAll('\\', '/')
  28. clone.geometricError = 0
  29. delete clone.children
  30. leaves.push(clone)
  31. return leaves
  32. }
  33. for (const child of tile.children || []) collectFineLeaves(child, leaves)
  34. return leaves
  35. }
  36. function boxBounds(tile) {
  37. const box = tile.boundingVolume?.box
  38. if (box?.length >= 12) return { min: [box[0] - box[3], box[1] - box[7], box[2] - box[11]],
  39. max: [box[0] + box[3], box[1] + box[7], box[2] + box[11]] }
  40. const sphere = tile.boundingVolume?.sphere
  41. assert.ok(sphere?.length >= 4, 'hybrid source requires box or sphere bounds')
  42. return { min: [sphere[0] - sphere[3], sphere[1] - sphere[3], sphere[2] - sphere[3]],
  43. max: [sphere[0] + sphere[3], sphere[1] + sphere[3], sphere[2] + sphere[3]] }
  44. }
  45. function center(bounds) { return bounds.min.map((value, axis) => (value + bounds.max[axis]) / 2) }
  46. function contains(bounds, point) { return point.every((value, axis) => value >= bounds.min[axis] && value <= bounds.max[axis]) }
  47. function distanceSquared(a, b) { return a.reduce((sum, value, axis) => sum + (value - b[axis]) ** 2, 0) }
  48. const farChildren = farTree.root.children.map(adaptFar)
  49. const fineLeaves = collectFineLeaves(fineTree.root)
  50. assert.equal(farChildren.length, 74)
  51. assert.equal(fineLeaves.length, 3023)
  52. // Attach each fine leaf beneath the coarse parent containing its center. This preserves
  53. // REPLACE semantics: the 74 coarse contents are the fallback until that local parent's
  54. // fine children are ready, rather than 3,023 coarse siblings being traversed at once.
  55. const parentBounds = farChildren.map(boxBounds)
  56. const parentCenters = parentBounds.map(center)
  57. for (const leaf of fineLeaves) {
  58. const point = center(boxBounds(leaf))
  59. let index = parentBounds.findIndex((bounds) => contains(bounds, point))
  60. if (index < 0) index = parentCenters.reduce((best, value, candidate) =>
  61. distanceSquared(value, point) < distanceSquared(parentCenters[best], point) ? candidate : best, 0)
  62. const parent = farChildren[index]
  63. parent.children ||= []
  64. parent.refine = 'REPLACE'
  65. parent.children.push(leaf)
  66. }
  67. const rootBox = farTree.root.boundingVolume
  68. mkdirSync(output, { recursive: true })
  69. const anchorPath = resolve(farDir, farTree.root.content.uri)
  70. assert.ok(existsSync(anchorPath), anchorPath)
  71. writeFileSync(join(output, 'root-anchor.glb'), readFileSync(anchorPath))
  72. writeFileSync(join(output, 'tileset.json'), JSON.stringify({
  73. asset: { version: '1.1', generator: 'CAD hybrid 74 coarse parents with local fine leaves' },
  74. geometricError: 1000000,
  75. root: {
  76. transform: farTree.root.transform,
  77. boundingVolume: rootBox,
  78. geometricError: 1000000,
  79. refine: 'REPLACE',
  80. content: { uri: 'root-anchor.glb' },
  81. children: farChildren,
  82. },
  83. }, null, 2) + '\n')
  84. const report = {
  85. buildings: 30172,
  86. farCoarseFiles: farChildren.length,
  87. fineFiles: fineLeaves.length,
  88. policy: '74-file far coarse siblings remain visible while only local GE0 fine leaves request within their bounded viewer spheres. No EXT_mesh_gpu_instancing is required; geometry is reused from immutable GLBs.',
  89. source: { far: 'far-roofed2', fine: 'city-continuous-v5' },
  90. traversalValidated: false,
  91. }
  92. writeFileSync(join(output, 'manifest.json'), JSON.stringify(report, null, 2) + '\n')
  93. console.log(JSON.stringify({ output, buildings: report.buildings, farCoarseFiles: report.farCoarseFiles, fineFiles: report.fineFiles }))