preview-district.mjs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. // Real Cesium/DEM screenshots, with camera range readback. No live config is modified.
  2. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import { PNG } from 'pngjs'
  5. const BASE = process.env.MODEL_REVIEW_BASE || 'http://127.0.0.1:8092'
  6. const DIR = resolve('tools/city-massing/district/generated')
  7. const OUT = resolve('.scratch/cockpit-shell-panel-layout/verify/artifacts/t35/district')
  8. mkdirSync(OUT, { recursive: true })
  9. const plan = JSON.parse(readFileSync(resolve(DIR, 'placement-report.json')))
  10. const puppeteer = (
  11. await import(
  12. 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  13. )
  14. ).default
  15. const browser = await puppeteer.launch({
  16. executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
  17. headless: true,
  18. defaultViewport: { width: 1500, height: 1000 },
  19. args: ['--no-sandbox'],
  20. })
  21. const images = [],
  22. requests = [],
  23. errors = []
  24. const page = await browser.newPage()
  25. page.on('pageerror', (e) => errors.push(e.message))
  26. page.on('response', (r) => {
  27. if (r.url().endsWith('.glb')) requests.push({ url: r.url(), status: r.status() })
  28. })
  29. try {
  30. await page.goto(BASE + '/preview.html#automation', { waitUntil: 'domcontentloaded' })
  31. await page.waitForFunction(() => typeof window.startPreview === 'function')
  32. await page.evaluate(() => window.startPreview({
  33. models: [
  34. '/review-assets/district/lod-preview/tileset.json',
  35. '/review-assets/city/regular-estate/lod-preview/tileset.json',
  36. '/review-assets/city/oldtown/lod-preview/tileset.json',
  37. ],
  38. }))
  39. const apartment =
  40. plan.buildings.find((b) => b.sourceId === 'osm-1285336673') ||
  41. plan.buildings.find((b) => b.asset.includes('slab'))
  42. const tower = plan.buildings.find((b) => b.asset.includes('midrise'))
  43. const cases = [
  44. {
  45. id: '01-satellite-control',
  46. lon: 110.395,
  47. lat: 28.454,
  48. height: 135,
  49. range: 1500,
  50. pitch: -89,
  51. heading: 0,
  52. show: false,
  53. label: '原始卫星影像 · 屋顶对照',
  54. },
  55. {
  56. id: '02-neighborhood-overview',
  57. lon: 110.395,
  58. lat: 28.454,
  59. height: 135,
  60. range: 1300,
  61. pitch: -50,
  62. heading: 10,
  63. show: true,
  64. label: '示范街区 · 有来源的逐栋布置 · 未覆盖区域保留原影像',
  65. },
  66. {
  67. id: '03-near-apartment',
  68. ...apartment.center,
  69. height: apartment.floorHeight + 9,
  70. range: 230,
  71. pitch: -30,
  72. heading: 200,
  73. show: true,
  74. label: '多层住宅 · 230m 实际视距 · 门窗 / 阳台 / 屋檐',
  75. },
  76. {
  77. id: '04-near-tower',
  78. ...tower.center,
  79. height: tower.floorHeight + 18,
  80. range: 300,
  81. pitch: -25,
  82. heading: 170,
  83. show: true,
  84. label: '较高住宅 · 300m 实际视距 · 台阶屋顶 / 层次变化',
  85. },
  86. {
  87. id: '05-top-built',
  88. lon: 110.395,
  89. lat: 28.454,
  90. height: 135,
  91. range: 1500,
  92. pitch: -89,
  93. heading: 0,
  94. show: true,
  95. label: '同视角顶视 · 与原始影像轮廓核对',
  96. },
  97. {
  98. id: '06-near-facade',
  99. ...apartment.center,
  100. height: apartment.floorHeight + 8,
  101. range: 150,
  102. pitch: -22,
  103. heading: 200,
  104. show: true,
  105. label: '住宅立面 · 150m 实际视距 · 非特写近景',
  106. },
  107. {
  108. id: '07-mobile-near',
  109. ...apartment.center,
  110. height: apartment.floorHeight + 8,
  111. range: 230,
  112. pitch: -26,
  113. heading: 200,
  114. show: true,
  115. mobile: true,
  116. label: '窄屏样片 · 230m 实际视距',
  117. },
  118. ]
  119. for (const a of cases) {
  120. if (a.mobile) {
  121. await page.setViewport({ width: 430, height: 932 })
  122. await page.evaluate(() => window.scene35.v.resize())
  123. }
  124. await page.evaluate((a) => {
  125. const { C, v, tiles } = window.scene35
  126. tiles.forEach((t) => (t.show = a.show))
  127. v.camera.lookAtTransform(
  128. C.Transforms.eastNorthUpToFixedFrame(C.Cartesian3.fromDegrees(a.lon, a.lat, a.height)),
  129. new C.HeadingPitchRange(C.Math.toRadians(a.heading), C.Math.toRadians(a.pitch), a.range)
  130. )
  131. document.querySelector('#caption').textContent = a.label + ' | 立面与楼层为估计,非测绘数据'
  132. }, a)
  133. await page.waitForFunction(() => window.scene35.v.scene.globe._surface._tilesToRender.length > 0, {
  134. timeout: 45000,
  135. })
  136. if (a.show)
  137. await page.waitForFunction(() => window.scene35.tiles.some(tile => tile.root?.content && tile.tilesLoaded), {
  138. timeout: 45000,
  139. })
  140. const f = await page.evaluate(() => window.frameCount)
  141. await page.waitForFunction((f) => window.frameCount > f + 60, { timeout: 20000 }, f)
  142. const state = await page.evaluate(() => {
  143. const { v, tiles, E, C } = window.scene35
  144. return {
  145. range: C.Cartesian3.magnitude(v.camera.position),
  146. depth: v.scene.globe.depthTestAgainstTerrain,
  147. groundTiles: v.scene.globe._surface._tilesToRender.length,
  148. tiles: E.readTilesetDiagnostics(tiles),
  149. }
  150. })
  151. if (Math.abs(state.range - a.range) > 1) throw Error('Camera range was not applied')
  152. const file = resolve(OUT, a.id + '.png')
  153. const png = PNG.sync.read(Buffer.from(await page.screenshot({ path: file })))
  154. // Capture the same canvas with the model hidden; actual model pixels must differ in the central scene.
  155. let modelPixels = null
  156. if (a.show) {
  157. await page.evaluate(() => window.scene35.tiles.forEach((t) => (t.show = false)))
  158. const hiddenFrame = await page.evaluate(() => window.frameCount)
  159. await page.waitForFunction((f) => window.frameCount > f + 3, {}, hiddenFrame)
  160. const hidden = PNG.sync.read(Buffer.from(await page.screenshot()))
  161. modelPixels = 0
  162. for (let y = 60; y < png.height - 100; y++) for (let x = 10; x < png.width - 10; x++) {
  163. const i = (y * png.width + x) * 4
  164. if (Math.abs(png.data[i] - hidden.data[i]) + Math.abs(png.data[i + 1] - hidden.data[i + 1]) + Math.abs(png.data[i + 2] - hidden.data[i + 2]) > 45) modelPixels++
  165. }
  166. if (modelPixels < 100) throw Error('Model has no visible rendered canvas pixels at ' + a.id)
  167. await page.evaluate(() => window.scene35.tiles.forEach((t) => (t.show = true)))
  168. }
  169. images.push({ ...a, ...state, modelPixels, file })
  170. console.log(a.id, JSON.stringify(state))
  171. }
  172. writeFileSync(resolve(OUT, 'report.json'), JSON.stringify({ images, requests, errors }, null, 2))
  173. if (errors.length) throw Error(errors.join('\n'))
  174. } catch (error) {
  175. await page.screenshot({ path: resolve(OUT, 'failure.png') })
  176. throw error
  177. } finally {
  178. await browser.close()
  179. }