build-aligned-far-tileset.mjs 4.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. // Build one GIS-aligned far tileset from the coarse LOD1 files of the active CAD batches.
  2. // The wrapper intentionally contains no roads or procedural fill; every content GLB comes from
  3. // the same seated plan used by the near/fine batches.
  4. import assert from 'node:assert/strict'
  5. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  6. import { join, resolve } from 'node:path'
  7. const root = resolve('tools/city-massing/city/cad-bim')
  8. const outputName = process.argv[2] || 'far-aligned'
  9. const suffix = process.argv[3] || 'aligned'
  10. assert.match(outputName, /^far-[a-z0-9-]+$/)
  11. assert.ok(['aligned', 'roofed'].includes(suffix), 'unknown CAD batch generation')
  12. const out = join(root, outputName)
  13. const batches = ['lod-pilot', 'lod-batch-urban8000-dedup', 'lod-batch-urban8000b',
  14. 'lod-batch-urban8000c', 'lod-batch-urban-final', 'lod-batch-urban-tail',
  15. 'lod-batch-unfitted-fallback'].map((name) => `${name}-${suffix}`)
  16. const boxFromBounds = ({ min, max }) => {
  17. const center = min.map((value, axis) => (value + max[axis]) / 2)
  18. const half = min.map((value, axis) => Math.max(0.001, (max[axis] - value) / 2))
  19. return [center[0], center[1], center[2], half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
  20. }
  21. const boundsFromBox = (box) => ({
  22. min: [box[0] - box[3], box[1] - box[7], box[2] - box[11]],
  23. max: [box[0] + box[3], box[1] + box[7], box[2] + box[11]],
  24. })
  25. const extend = (target, bounds) => {
  26. for (let axis = 0; axis < 3; axis++) {
  27. target.min[axis] = Math.min(target.min[axis], bounds.min[axis])
  28. target.max[axis] = Math.max(target.max[axis], bounds.max[axis])
  29. }
  30. }
  31. mkdirSync(out, { recursive: true })
  32. const children = []
  33. const buildings = new Set()
  34. const sourceBatches = []
  35. let transform = null
  36. const bounds = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }
  37. for (const batch of batches) {
  38. const dir = join(root, batch)
  39. const tree = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
  40. const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
  41. transform ||= tree.root.transform
  42. assert.deepEqual(tree.root.transform, transform, `${batch}: transform differs from other CAD batches`)
  43. if (suffix === 'roofed') assert.equal(manifest.provenance?.closedRoofCaps, true, `${batch}: missing roof caps`)
  44. const rootBounds = manifest.boundsENU
  45. ? { min: manifest.boundsENU.min, max: manifest.boundsENU.max }
  46. : boundsFromBox(tree.root.boundingVolume.box)
  47. extend(bounds, rootBounds)
  48. for (const cell of manifest.cells) for (const id of cell.buildingIds) {
  49. assert.ok(!buildings.has(id), `duplicate building id ${id}`)
  50. buildings.add(id)
  51. }
  52. const coarse = Array.isArray(tree.root.children) ? tree.root.children : []
  53. assert.ok(coarse.length, `${batch}: no coarse children`)
  54. for (const tile of coarse) {
  55. assert.ok(tile.content?.uri, `${batch}: coarse child has no content`)
  56. const childBounds = boundsFromBox(tile.boundingVolume.box)
  57. extend(bounds, childBounds)
  58. children.push({
  59. id: `${batch}/${tile.content.uri}`,
  60. boundingVolume: tile.boundingVolume,
  61. geometricError: Math.max(1, tile.geometricError || 16),
  62. refine: 'REPLACE',
  63. content: { uri: `../${batch}/${tile.content.uri}` },
  64. })
  65. }
  66. sourceBatches.push({ batch, buildings: manifest.count, coarseFiles: coarse.length })
  67. }
  68. assert.equal(buildings.size, sourceBatches.reduce((sum, item) => sum + item.buildings, 0),
  69. 'far wrapper building coverage does not match batch counts')
  70. const tileset = {
  71. asset: { version: '1.1', generator: 'CAD GIS-aligned far coarse wrapper' },
  72. geometricError: 800,
  73. root: {
  74. transform,
  75. boundingVolume: { box: boxFromBounds(bounds) },
  76. geometricError: 200,
  77. refine: 'REPLACE',
  78. content: { uri: `../${batches[0]}/root-anchor.glb` },
  79. children,
  80. },
  81. }
  82. const report = {
  83. generator: 'CAD GIS-aligned far coarse wrapper',
  84. sourceBatches,
  85. buildings: buildings.size,
  86. coarseFiles: children.length,
  87. closedRoofCaps: suffix === 'roofed',
  88. boundsENU: bounds,
  89. note: 'Every coarse GLB is emitted from the same seated GIS plan as the fine CAD/BIM batches. No roads or procedural fill are included.',
  90. }
  91. writeFileSync(join(out, 'tileset.json'), JSON.stringify(tileset, null, 2) + '\n')
  92. writeFileSync(join(out, 'manifest.json'), JSON.stringify(report, null, 2) + '\n')
  93. console.log(JSON.stringify({ output: out, buildings: buildings.size, coarseFiles: children.length, bounds }))