verify-town.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. /**
  2. * 沅陵轻量体块的独立回验(票 34)。
  3. *
  4. * 这套几何是**程序化生成**的,所以"看起来对"不够 —— 这里验的是三件可证伪的事:
  5. * 1. **地理配准**:取一个真实 OSM 轮廓的质心,按生成时的同一条投影链推出它在 GLB 里的应有位置,
  6. * 再在 GLB 顶点里找得到它(容差 1m)→ 证明"经纬度 → 局部 ENU → glTF Y-up"这条链没写反;
  7. * 2. **tileset transform 自洽**:把 GLB 包围盒中心经 transform 送到经纬度,应落在目标城区窗口内;
  8. * 3. **GLB 结构合法**:header/JSON/BIN 长度自洽、accessor 与 bufferView 对得上、无 NaN。
  9. *
  10. * 用法:node tools/city-massing/verify-town.mjs
  11. */
  12. import { readFileSync } from 'node:fs'
  13. import { join } from 'node:path'
  14. const DATA_DIR = process.env.CITY_DATA_DIR || 'D:/GIS/GIS/cityMassing'
  15. const ORIGIN = { lon: 110.39028164, lat: 28.4436787 }
  16. const DEG = Math.PI / 180
  17. const M_PER_DEG_LAT = 110540
  18. const M_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * DEG)
  19. const OSM = 'D:/GIS/GIS/planet_110.059,28.258_110.744,28.625-shp/shpOutput'
  20. const fails = []
  21. function check(name, pass, evidence) {
  22. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  23. if (!pass) fails.push(name)
  24. }
  25. // ---------------------------------------------------------------- 解析 GLB
  26. const buf = readFileSync(join(DATA_DIR, 'town.glb'))
  27. const magic = buf.toString('ascii', 0, 4)
  28. const version = buf.readUInt32LE(4)
  29. const totalLen = buf.readUInt32LE(8)
  30. check('GLB-header', magic === 'glTF' && version === 2 && totalLen === buf.length, `${magic} v${version} len=${totalLen}/${buf.length}`)
  31. const jsonLen = buf.readUInt32LE(12)
  32. const jsonType = buf.toString('ascii', 16, 20)
  33. const gltf = JSON.parse(buf.toString('utf8', 20, 20 + jsonLen))
  34. check('GLB-json-chunk', jsonType === 'JSON' && !!gltf.meshes, `${jsonType}, primitives=${gltf.meshes[0].primitives.length}`)
  35. const binStart = 20 + jsonLen + 8
  36. const binChunkLen = buf.readUInt32LE(20 + jsonLen)
  37. check('GLB-bin-chunk', binStart + binChunkLen <= buf.length, `bin=${binChunkLen}`)
  38. function readAccessor(index) {
  39. const acc = gltf.accessors[index]
  40. const view = gltf.bufferViews[acc.bufferView]
  41. const offset = binStart + (view.byteOffset || 0)
  42. const out = []
  43. if (acc.componentType === 5126) {
  44. for (let i = 0; i < acc.count * 3; i++) out.push(buf.readFloatLE(offset + i * 4))
  45. } else if (acc.componentType === 5125) {
  46. for (let i = 0; i < acc.count; i++) out.push(buf.readUInt32LE(offset + i * 4))
  47. }
  48. return out
  49. }
  50. // ---------------------------------------------------------------- 3. 结构 + NaN
  51. let vertexCount = 0, triCount = 0, nan = 0
  52. for (const prim of gltf.meshes[0].primitives) {
  53. const pos = readAccessor(prim.attributes.POSITION)
  54. const idx = readAccessor(prim.indices)
  55. vertexCount += pos.length / 3
  56. triCount += idx.length / 3
  57. for (const v of pos) if (!Number.isFinite(v)) nan += 1
  58. const acc = gltf.accessors[prim.attributes.POSITION]
  59. check(
  60. `POSITION-accessor-minmax-${prim.material}`,
  61. Array.isArray(acc.min) && Array.isArray(acc.max) && acc.min.every((v, i) => v <= acc.max[i]),
  62. `min=[${acc.min.map((v) => v.toFixed(0))}] max=[${acc.max.map((v) => v.toFixed(0))}]`,
  63. )
  64. }
  65. check('geometry-sane', vertexCount > 1000 && triCount > 1000 && nan === 0, `顶点 ${vertexCount},三角 ${triCount},NaN ${nan}`)
  66. // ---------------------------------------------------------------- 1. 真实轮廓回验
  67. // 生成器把体块**居中**放在轮廓外接盒中心上,所以顶点落在**外接盒的四角**(真实轮廓不做旋转)。
  68. // 因此判据是「GLB 里存在一个顶点落在该轮廓的外接盒角点上」——这条能证伪整条坐标链
  69. // (经纬度 → 局部 ENU → glTF Y-up),写反任何一步都对不上。
  70. // 同时按生成器的规则剔掉贴水的轮廓(那些本来就被避让掉了)。
  71. const buildings = JSON.parse(readFileSync(join(OSM, 'buildings.json'), 'utf8')).features
  72. const waterways = JSON.parse(readFileSync(join(OSM, 'waterways.json'), 'utf8')).features
  73. const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= 0.02 && Math.abs(lat - ORIGIN.lat) <= 0.015
  74. const toLocal = (lon, lat) => ({ x: (lon - ORIGIN.lon) * M_PER_DEG_LON, y: (lat - ORIGIN.lat) * M_PER_DEG_LAT })
  75. const waterLines = []
  76. for (const f of waterways) {
  77. const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null
  78. if (!ring) continue
  79. const seg = []
  80. for (const [lon, lat] of ring) {
  81. if (inWindow(lon, lat)) seg.push(toLocal(lon, lat))
  82. else if (seg.length) { waterLines.push(seg); seg.length = 0 }
  83. }
  84. if (seg.length) waterLines.push(seg)
  85. }
  86. function distanceToWater(p) {
  87. let best = Infinity
  88. for (const line of waterLines) {
  89. for (let i = 1; i < line.length; i++) {
  90. const a = line[i - 1], b = line[i]
  91. const dx = b.x - a.x, dy = b.y - a.y
  92. const len2 = dx * dx + dy * dy || 1
  93. let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2
  94. t = Math.max(0, Math.min(1, t))
  95. best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)))
  96. }
  97. }
  98. return best
  99. }
  100. const targets = []
  101. for (const f of buildings) {
  102. const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
  103. if (!ring) continue
  104. const pts = ring.filter(([lon, lat]) => inWindow(lon, lat))
  105. if (pts.length < 4) continue
  106. const ex = pts.map(([lon]) => (lon - ORIGIN.lon) * M_PER_DEG_LON)
  107. const ny = pts.map(([, lat]) => (lat - ORIGIN.lat) * M_PER_DEG_LAT)
  108. const minX = Math.min(...ex), maxX = Math.max(...ex)
  109. const minY = Math.min(...ny), maxY = Math.max(...ny)
  110. const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
  111. if (distanceToWater({ x: cx, y: cy }) < 22) continue // 生成器会避让贴水的
  112. targets.push({ corners: [[minX, minY], [maxX, minY], [minX, maxY], [maxX, maxY]], area: (maxX - minX) * (maxY - minY) })
  113. }
  114. targets.sort((a, b) => b.area - a.area)
  115. const sample = targets.slice(0, 8)
  116. // 把所有顶点按 ENU 还原(glTF Y-up → ENU:east=x, up=y, north=-z)
  117. const allVerts = []
  118. for (const prim of gltf.meshes[0].primitives) {
  119. const pos = readAccessor(prim.attributes.POSITION)
  120. for (let i = 0; i < pos.length; i += 3) {
  121. allVerts.push({ x: pos[i], z: -pos[i + 2] }) // east, north(忽略 up)
  122. }
  123. }
  124. let hit = 0
  125. const details = []
  126. for (const t of sample) {
  127. const found = t.corners.some(([cx2, cy2]) => allVerts.some((v) => Math.abs(v.x - cx2) <= 2 && Math.abs(v.z - cy2) <= 2))
  128. if (found) hit += 1
  129. details.push(`${t.area.toFixed(0)}m²@(${t.corners[0][0].toFixed(0)},${t.corners[0][1].toFixed(0)})${found ? '✓' : '✗'}`)
  130. }
  131. check(
  132. 'real-footprints-land-on-true-coords',
  133. sample.length >= 5 && hit === sample.length,
  134. `${hit}/${sample.length} 个最大真实轮廓的外接盒角点在 GLB 里 2m 内命中:${details.join(' ')}`,
  135. )
  136. // ---------------------------------------------------------------- 2. transform 自洽
  137. const T = JSON.parse(readFileSync(join(DATA_DIR, 'tileset.json'), 'utf8')).root.transform
  138. const box = JSON.parse(readFileSync(join(DATA_DIR, 'tileset.json'), 'utf8')).root.boundingVolume.box
  139. function applyTransform([x, y, z]) {
  140. return [
  141. T[0] * x + T[4] * y + T[8] * z + T[12],
  142. T[1] * x + T[5] * y + T[9] * z + T[13],
  143. T[2] * x + T[6] * y + T[10] * z + T[14],
  144. ]
  145. }
  146. function ecefToLonLat([X, Y, Z]) {
  147. const a = 6378137, f = 1 / 298.257223563, e2 = 2 * f - f * f
  148. const lon = Math.atan2(Y, X)
  149. const p = Math.hypot(X, Y)
  150. let lat = Math.atan2(Z, p * (1 - e2))
  151. for (let i = 0; i < 5; i++) {
  152. const s = Math.sin(lat)
  153. const N = a / Math.sqrt(1 - e2 * s * s)
  154. lat = Math.atan2(Z + e2 * N * s, p)
  155. }
  156. return { lon: (lon * 180) / Math.PI, lat: (lat * 180) / Math.PI }
  157. }
  158. // 原点自身(局部 0,0,0)应回到目标坐标
  159. const originLL = ecefToLonLat(applyTransform([0, 0, 0]))
  160. const dOrigin = Math.hypot((originLL.lon - ORIGIN.lon) * M_PER_DEG_LON, (originLL.lat - ORIGIN.lat) * M_PER_DEG_LAT)
  161. check('transform-origin-roundtrip', dOrigin < 0.5, `原点误差 ${dOrigin.toFixed(3)} m(${originLL.lon.toFixed(6)}, ${originLL.lat.toFixed(6)})`)
  162. // 包围盒中心经 transform 后应落在窗口内
  163. const corner = ecefToLonLat(applyTransform([box[0], box[1], box[2]]))
  164. check(
  165. 'bbox-center-in-window',
  166. Math.abs(corner.lon - ORIGIN.lon) <= 0.025 && Math.abs(corner.lat - ORIGIN.lat) <= 0.02,
  167. `包围盒中心 → ${corner.lon.toFixed(5)}, ${corner.lat.toFixed(5)}`,
  168. )
  169. const meta = JSON.parse(readFileSync(join(DATA_DIR, 'meta.json'), 'utf8'))
  170. check('disclaimer-present', typeof meta._disclaimer === 'string' && meta._disclaimer.includes('不是实测建筑'), meta._disclaimer.slice(0, 40) + '…')
  171. console.log('')
  172. if (fails.length) {
  173. console.log(`回验未通过:${fails.join(', ')}`)
  174. process.exit(1)
  175. }
  176. console.log(`回验全部通过:${meta.counts.total} 栋体块(真实轮廓 ${meta.counts.real})、${(meta.glbBytes / 1024).toFixed(0)} KB、${meta.materials} 种材质。`)