trace-regular-estate.mjs 3.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. // Georeference individually traced roof axes from the verified TMS mosaic and retain the satellite comparison.
  2. import { readFileSync, writeFileSync } from 'node:fs'
  3. import { resolve } from 'node:path'
  4. import jpeg from 'jpeg-js'
  5. import { PNG } from 'pngjs'
  6. import * as turf from '@turf/turf'
  7. const dir = resolve('tools/city-massing/city')
  8. const config = JSON.parse(readFileSync(resolve(dir, 'regular-estate-roofs.json'), 'utf8'))
  9. const [zoom, x0, y0, size] = [config.zoom, config.tileX0, config.googleY0, config.tileSize]
  10. const pixelToGeo = ([x, y]) => {
  11. const scale = 2 ** zoom
  12. const lon = (360 * (x0 + x / size)) / scale - 180
  13. const lat = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y0 + y / size)) / scale))) * 180) / Math.PI
  14. return [lon, lat]
  15. }
  16. const imagePath = resolve('.scratch/gis-investigation-subagent/regular-z19.jpg')
  17. const source = jpeg.decode(readFileSync(imagePath))
  18. const annotated = new PNG({ width: source.width, height: source.height })
  19. annotated.data.set(source.data)
  20. const features = []
  21. function line(a, b, color) {
  22. const count = Math.ceil(Math.hypot(a[0] - b[0], a[1] - b[1]))
  23. for (let i = 0; i <= count; i++) {
  24. const x = Math.round(a[0] + ((b[0] - a[0]) * i) / count)
  25. const y = Math.round(a[1] + ((b[1] - a[1]) * i) / count)
  26. if (x < 0 || y < 0 || x >= annotated.width || y >= annotated.height) continue
  27. const offset = (y * annotated.width + x) * 4
  28. for (let c = 0; c < 3; c++) annotated.data[offset + c] = color[c]
  29. annotated.data[offset + 3] = 255
  30. }
  31. }
  32. for (const roof of config.roofStrips) {
  33. const [a, b] = roof.axisPx
  34. const d = Math.hypot(b[0] - a[0], b[1] - a[1])
  35. if (d < roof.roofWidthPx * 2) throw new Error(`Roof strip too short: ${roof.id}`)
  36. const perpendicular = [-(b[1] - a[1]) / d, (b[0] - a[0]) / d]
  37. const [dx, dy] = perpendicular.map((v) => (v * roof.roofWidthPx) / 2)
  38. const cornersPx = [
  39. [a[0] + dx, a[1] + dy],
  40. [b[0] + dx, b[1] + dy],
  41. [b[0] - dx, b[1] - dy],
  42. [a[0] - dx, a[1] - dy],
  43. ]
  44. const geo = cornersPx.map(pixelToGeo)
  45. const ring = [...geo, geo[0]]
  46. for (let j = 0; j < 4; j++) line(cornersPx[j], cornersPx[(j + 1) % 4], [255, 208, 0])
  47. features.push(
  48. turf.polygon([ring], {
  49. id: `regular-${roof.id}`,
  50. source: 'traced-roof',
  51. atlas: 'regular-estate-roofs.json',
  52. floors: roof.floors,
  53. heightSource: 'estimated',
  54. archetype: 'slab',
  55. cornersPx,
  56. roofAreaSqm: Math.round(turf.area(turf.polygon([ring]))),
  57. })
  58. )
  59. }
  60. const overlaps = []
  61. for (let i = 0; i < features.length; i++)
  62. for (let j = i + 1; j < features.length; j++) {
  63. if (
  64. turf.booleanOverlap(features[i], features[j]) ||
  65. turf.booleanContains(features[i], features[j]) ||
  66. turf.booleanContains(features[j], features[i])
  67. )
  68. overlaps.push([features[i].properties.id, features[j].properties.id])
  69. }
  70. writeFileSync(resolve(dir, 'regular-estate-audit.png'), PNG.sync.write(annotated))
  71. if (overlaps.length) throw new Error(`Roof traces overlap: ${JSON.stringify(overlaps)}`)
  72. writeFileSync(resolve(dir, 'regular-estate-roofs.geojson'), JSON.stringify(turf.featureCollection(features), null, 2))
  73. console.log(
  74. JSON.stringify({
  75. site: config.site,
  76. roofs: features.length,
  77. areaSqm: features.reduce((sum, f) => sum + f.properties.roofAreaSqm, 0),
  78. overlaps,
  79. photo: imagePath,
  80. })
  81. )