sample-terrain-offline.mjs 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. // Sample the original local quantized-mesh DEM directly through Cesium; no dev server or interpolated grid.
  2. import { readFileSync, writeFileSync, existsSync } from 'node:fs'
  3. import { resolve, relative } from 'node:path'
  4. import { createHash } from 'node:crypto'
  5. import { CesiumTerrainProvider, Cartographic, sampleTerrainMostDetailed } from 'cesium'
  6. const planFile = resolve(process.argv[2] || 'tools/city-massing/city/regular-estate/plan.json')
  7. const output = resolve(process.argv[3] || 'tools/city-massing/city/regular-estate/terrain-samples.json')
  8. const dem = resolve(process.env.YUANLING_DEM || 'D:/GIS/GIS/yuanlingDEM')
  9. const plan = JSON.parse(readFileSync(planFile, 'utf8'))
  10. if (!Array.isArray(plan.samples) || !plan.samples.length) throw new Error('Plan has no footprint samples')
  11. const origin = 'https://local-yuanling-dem.invalid/gis/dem/'
  12. const originalFetch = globalThis.fetch
  13. globalThis.fetch = (input, init) => {
  14. const url = String(input?.url || input)
  15. if (!url.startsWith(origin)) return originalFetch(input, init)
  16. const path = resolve(dem, decodeURIComponent(new URL(url).pathname.slice('/gis/dem/'.length)))
  17. if (relative(dem, path).startsWith('..')) throw new Error(`Unsafe local terrain path: ${url}`)
  18. if (!existsSync(path)) throw new Error(`Missing original terrain tile: ${path}`)
  19. return Promise.resolve(
  20. new Response(readFileSync(path), {
  21. status: 200,
  22. headers: { 'Content-Type': path.endsWith('.json') ? 'application/json' : 'application/octet-stream' },
  23. })
  24. )
  25. }
  26. try {
  27. const provider = await CesiumTerrainProvider.fromUrl(origin)
  28. const heights = []
  29. let missing = 0
  30. for (let i = 0; i < plan.samples.length; i += 128) {
  31. const points = plan.samples.slice(i, i + 128).map((p) => Cartographic.fromDegrees(p.lon, p.lat))
  32. const values = await sampleTerrainMostDetailed(provider, points, false)
  33. for (const value of values) {
  34. // A sample outside the DEM's coverage is recorded as null and reported, never guessed.
  35. // The seating stage then excludes the specific module instead of failing the whole city.
  36. if (!Number.isFinite(value.height) || value.height <= 1) {
  37. missing++
  38. heights.push(null)
  39. } else {
  40. heights.push(value.height)
  41. }
  42. }
  43. }
  44. const digest = createHash('sha256').update(JSON.stringify(plan.samples)).digest('hex')
  45. writeFileSync(
  46. output,
  47. JSON.stringify(
  48. {
  49. source: dem,
  50. layerJsonSha256: createHash('sha256')
  51. .update(readFileSync(resolve(dem, 'layer.json')))
  52. .digest('hex'),
  53. samplesSha256: digest,
  54. points: plan.samples,
  55. heights,
  56. missingSamples: missing,
  57. },
  58. null,
  59. 2
  60. )
  61. )
  62. // Extremes by loop: spreading hundreds of thousands of values overflows the call stack.
  63. let minHeight = null
  64. let maxHeight = null
  65. for (const height of heights) {
  66. if (height === null) continue
  67. if (minHeight === null || height < minHeight) minHeight = height
  68. if (maxHeight === null || height > maxHeight) maxHeight = height
  69. }
  70. console.log(
  71. JSON.stringify({
  72. points: heights.length,
  73. missingSamples: missing,
  74. minHeight,
  75. maxHeight,
  76. output,
  77. })
  78. )
  79. } finally {
  80. globalThis.fetch = originalFetch
  81. }