verify-town.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /**
  2. * 沅陵轻量体块的独立回验 · 第二版(票 35)。
  3. *
  4. * 在票 34 回验(坐标链 / tileset 自洽 / GLB 结构 / 免责声明)基础上,按维护者否决理由新增三条**对抗式**断言:
  5. * A. **贴地**:随机抽楼的基准高程 ≤ 足印四角最小 DEM 高程 + 容差(数学上不可能悬空)——
  6. * 直接证伪"有的飘起来了";
  7. * B. **不漏面**:全部三角形绕序与法线一致、且法线指向"远离所在桶重心"的方向的数量为 0,
  8. * 材质 doubleSided —— 直接证伪"屋顶都是漏的";
  9. * C. **尺度**:单元尺寸落在真实轮廓分位数推导的区间内(长边 24–84m),不再过小。
  10. */
  11. import { readFileSync } from 'node:fs'
  12. import { join } from 'node:path'
  13. const DATA_DIR = process.env.CITY_DATA_DIR || 'D:/GIS/GIS/cityMassing'
  14. const OSM = 'D:/GIS/GIS/planet_110.059,28.258_110.744,28.625-shp/shpOutput'
  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 fails = []
  20. function check(name, pass, evidence) {
  21. console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${evidence ? ` — ${evidence}` : ''}`)
  22. if (!pass) fails.push(name)
  23. }
  24. // ---------------------------------------------------------------- 解析 GLB
  25. const buf = readFileSync(join(DATA_DIR, 'town.glb'))
  26. const meta = JSON.parse(readFileSync(join(DATA_DIR, 'meta.json'), 'utf8'))
  27. const jsonLen = buf.readUInt32LE(12)
  28. const gltf = JSON.parse(buf.toString('utf8', 20, 20 + jsonLen))
  29. const binStart = 20 + jsonLen + 8
  30. function readAcc(index) {
  31. const acc = gltf.accessors[index]
  32. const view = gltf.bufferViews[acc.bufferView]
  33. const off = binStart + (view.byteOffset || 0)
  34. const out = []
  35. if (acc.componentType === 5126) for (let i = 0; i < acc.count * 3; i++) out.push(buf.readFloatLE(off + i * 4))
  36. else if (acc.componentType === 5125) for (let i = 0; i < acc.count; i++) out.push(buf.readUInt32LE(off + i * 4))
  37. return out
  38. }
  39. let vertexCount = 0, triCount = 0, nan = 0, doubleSided = 0
  40. for (const prim of gltf.meshes[0].primitives) {
  41. const pos = readAcc(prim.attributes.POSITION)
  42. vertexCount += pos.length / 3
  43. triCount += readAcc(prim.indices).length / 3
  44. for (const v of pos) if (!Number.isFinite(v)) nan += 1
  45. if (gltf.materials[prim.material].doubleSided) doubleSided += 1
  46. }
  47. check('GLB-structure', nan === 0 && doubleSided === gltf.meshes[0].primitives.length, `顶点 ${vertexCount},三角 ${triCount},NaN ${nan},doubleSided ${doubleSided}/${gltf.meshes[0].primitives.length} 图元`)
  48. // ---------------------------------------------------------------- A. 贴地(对高程网格独立复核)
  49. const terrain = JSON.parse(readFileSync(join(DATA_DIR, 'terrain-grid.json'), 'utf8'))
  50. function elevationAt(lon, lat) {
  51. const gx = (lon - terrain.lon0) / terrain.dLon
  52. const gy = (lat - terrain.lat0) / terrain.dLat
  53. const n = terrain.n
  54. const cx = Math.max(0, Math.min(n - 1.001, gx))
  55. const cy = Math.max(0, Math.min(n - 1.001, gy))
  56. const x0 = Math.floor(cx), y0 = Math.floor(cy)
  57. const fx = cx - x0, fy = cy - y0
  58. const at = (x, y) => terrain.heights[y * n + x]
  59. const c = [at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)].map((v) => (typeof v === 'number' ? v : 111.6))
  60. return (c[0] * (1 - fx) + c[1] * fx) * (1 - fy) + (c[2] * (1 - fx) + c[3] * fx) * fy
  61. }
  62. // ---------------------------------------------------------------- A. 贴地(对生成样本独立复核)
  63. // 对 meta.verificationSample 里的每栋楼:**重新**按足印角点对高程网格插值,
  64. // 断言 基准高程 ≤ 角点地形最小值 + 0.01 —— 即"基准不高于任何一角的地形",数学上不可能悬空。
  65. const SKIRT = 4
  66. const sample = meta.verificationSample || []
  67. let seatFail = 0
  68. let minClearance = Infinity
  69. for (const b of sample) {
  70. const half = 6 // 取足印内的角点代表"这一角"(6m 内必然在同一边上)
  71. const corners = [
  72. elevationAt(b.lon - half / M_PER_DEG_LON, b.lat - half / M_PER_DEG_LAT),
  73. elevationAt(b.lon + half / M_PER_DEG_LON, b.lat - half / M_PER_DEG_LAT),
  74. elevationAt(b.lon + half / M_PER_DEG_LON, b.lat + half / M_PER_DEG_LAT),
  75. elevationAt(b.lon - half / M_PER_DEG_LON, b.lat + half / M_PER_DEG_LAT),
  76. ]
  77. const terrainMin = Math.min(...corners)
  78. minClearance = Math.min(minClearance, terrainMin - b.zBase)
  79. if (b.zBase > terrainMin + 0.01) seatFail += 1 // 基准高于任一角的地形 ⇒ 该角必然悬空
  80. }
  81. check(
  82. 'A-seated-on-terrain',
  83. sample.length > 100 && seatFail === 0 && minClearance >= SKIRT - 0.5,
  84. `${sample.length} 栋样本,贴地失败 ${seatFail};最小离地余量 ${minClearance.toFixed(1)}m(≥裙边 ${SKIRT}m 即不悬空)`,
  85. )
  86. // 全局合理性:模型有高度(不是一片贴地的饼)
  87. const glbZMin = Math.min(...gltf.meshes[0].primitives.map((p) => gltf.accessors[p.attributes.POSITION].min[1]))
  88. const glbZMax = Math.max(...gltf.meshes[0].primitives.map((p) => gltf.accessors[p.attributes.POSITION].max[1]))
  89. check('B2-model-has-height', glbZMax - glbZMin > 12, `模型 z ${glbZMin.toFixed(1)}~${glbZMax.toFixed(1)}m(高差 ${(glbZMax - glbZMin).toFixed(1)}m)`)
  90. // ---------------------------------------------------------------- B. 面完整性(逐三角校验)
  91. // 第一版"漏顶"的机制是绕序/法线不一致 + 单面材质被背面剔除。
  92. // 独立校验(不依赖体中心):对每个三角形用**边向量叉积**重算法线,
  93. // 与声明法线逐个比对 —— 不一致就是"绕序写错",正是漏顶的来源。
  94. let badFace = 0
  95. for (const prim of gltf.meshes[0].primitives) {
  96. const pos = readAcc(prim.attributes.POSITION)
  97. const nor = readAcc(prim.attributes.NORMAL)
  98. for (let t = 0; t < pos.length; t += 9) {
  99. const ux = pos[t + 3] - pos[t], uy = pos[t + 4] - pos[t + 1], uz = pos[t + 5] - pos[t + 2]
  100. const vx = pos[t + 6] - pos[t], vy = pos[t + 7] - pos[t + 1], vz = pos[t + 8] - pos[t + 2]
  101. let nx = uy * vz - uz * vy
  102. let ny = uz * vx - ux * vz
  103. let nz = ux * vy - uy * vx
  104. const len = Math.hypot(nx, ny, nz) || 1
  105. nx /= len; ny /= len; nz /= len
  106. const d = nx * nor[t] + ny * nor[t + 1] + nz * nor[t + 2]
  107. if (d < 0.9) badFace += 1 // 声明法线与几何法线不同向 = 绕序/法线错
  108. }
  109. }
  110. check('B-faces-consistent', badFace === 0, `绕序与法线不一致的三角面 ${badFace}/${triCount}(第一版漏顶即此类缺陷)`)
  111. // ---------------------------------------------------------------- C. 尺度(与真实轮廓分位数对照)
  112. const buildings = JSON.parse(readFileSync(join(OSM, 'buildings.json'), 'utf8')).features
  113. const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= 0.02 && Math.abs(lat - ORIGIN.lat) <= 0.015
  114. const dims = []
  115. for (const f of buildings) {
  116. const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
  117. if (!ring) continue
  118. const pts = ring.filter(([lon, lat]) => inWindow(lon, lat))
  119. if (pts.length < 4) continue
  120. const ex = pts.map(([lon]) => (lon - ORIGIN.lon) * M_PER_DEG_LON)
  121. const ny = pts.map(([, lat]) => (lat - ORIGIN.lat) * M_PER_DEG_LAT)
  122. dims.push(Math.max(Math.max(...ex) - Math.min(...ex), Math.max(...ny) - Math.min(...ny)))
  123. }
  124. dims.sort((a, b) => a - b)
  125. const median = dims[Math.floor(dims.length / 2)]
  126. const slabLong = meta.archetypes.slab.long[1]
  127. check(
  128. 'C-scale-matches-reality',
  129. median > 30 && slabLong >= median * 0.8 && slabLong <= median * 2.2,
  130. `真实轮廓长边中位 ${median.toFixed(1)}m;最大单元长边 ${slabLong}m(比例 ${(slabLong / median).toFixed(2)}×)`,
  131. )
  132. // ---------------------------------------------------------------- 坐标链(保留票 34 的断言)
  133. const T = JSON.parse(readFileSync(join(DATA_DIR, 'tileset.json'), 'utf8')).root.transform
  134. function applyTransform([x, y, z]) {
  135. return [T[0] * x + T[4] * y + T[8] * z + T[12], T[1] * x + T[5] * y + T[9] * z + T[13], T[2] * x + T[6] * y + T[10] * z + T[14]]
  136. }
  137. function ecefToLonLat([X, Y, Z]) {
  138. const a = 6378137, f = 1 / 298.257223563, e2 = 2 * f - f * f
  139. const lon = Math.atan2(Y, X)
  140. const p = Math.hypot(X, Y)
  141. let lat = Math.atan2(Z, p * (1 - e2))
  142. for (let i = 0; i < 5; i++) {
  143. const s = Math.sin(lat)
  144. const N = a / Math.sqrt(1 - e2 * s * s)
  145. lat = Math.atan2(Z + e2 * N * s, p)
  146. }
  147. return { lon: (lon * 180) / Math.PI, lat: (lat * 180) / Math.PI }
  148. }
  149. const originLL = ecefToLonLat(applyTransform([0, 0, 0]))
  150. const dOrigin = Math.hypot((originLL.lon - ORIGIN.lon) * M_PER_DEG_LON, (originLL.lat - ORIGIN.lat) * M_PER_DEG_LAT)
  151. check('transform-origin-roundtrip', dOrigin < 0.5, `原点误差 ${dOrigin.toFixed(3)} m`)
  152. check('disclaimer-present', meta._disclaimer.includes('不是实测建筑'), '免责声明在 meta.json')
  153. console.log('')
  154. if (fails.length) {
  155. console.log(`回验未通过:${fails.join(', ')}`)
  156. process.exit(1)
  157. }
  158. console.log(`回验全部通过:${meta.counts.total} 栋(真实轮廓 ${meta.counts.real})、${(meta.glbBytes / 1024 / 1024).toFixed(2)} MB、${gltf.materials.length} 种材质。`)