| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128 |
- // Subdivide the individually mapped apartment roofs into reusable 4/6-floor modules.
- // The roof outlines are not surveyed wall footprints: inset each body and label the uncertainty.
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
- import { resolve } from 'node:path'
- import { createHash } from 'node:crypto'
- import { Cartesian3, Cartographic, Matrix4, Transforms, Math as CM } from 'cesium'
- import * as turf from '@turf/turf'
- const OUT = resolve('tools/city-massing/city/regular-estate')
- mkdirSync(OUT, { recursive: true })
- const roofs = JSON.parse(readFileSync('tools/city-massing/city/regular-estate-roofs.geojson', 'utf8'))
- const source = JSON.parse(readFileSync('tools/city-massing/city/regular-estate-roofs.json', 'utf8'))
- const origin = { lon: 110.395, lat: 28.454 } // shared ENU anchor with the audited stadium sample
- const transform = Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(origin.lon, origin.lat))
- const inverse = Matrix4.inverseTransformation(transform, new Matrix4())
- const toLocal = ([lon, lat]) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat), new Cartesian3())
- const toGeo = (q) => {
- const c = Cartographic.fromCartesian(
- Matrix4.multiplyByPoint(transform, new Cartesian3(q[0], q[1], 0), new Cartesian3())
- )
- return { lon: CM.toDegrees(c.longitude), lat: CM.toDegrees(c.latitude) }
- }
- const rows = []
- const samples = []
- const modules = []
- for (const f of roofs.features) {
- const corners = f.geometry.coordinates[0].slice(0, 4).map(toLocal)
- const left = Cartesian3.midpoint(corners[0], corners[3], new Cartesian3())
- const right = Cartesian3.midpoint(corners[1], corners[2], new Cartesian3())
- const center = Cartesian3.midpoint(left, right, new Cartesian3())
- const long = Cartesian3.subtract(right, left, new Cartesian3())
- const width = Cartesian3.magnitude(long)
- const depth = Cartesian3.distance(corners[0], corners[3])
- const u = [long.x / width, long.y / width],
- v = [-u[1], u[0]]
- const moduleCount = Math.max(1, Math.ceil((width - 2) / 34))
- const gap = 1.2
- const moduleWidth = (width - 2 - gap * (moduleCount - 1)) / moduleCount
- const moduleDepth = depth - 1.4
- if (moduleWidth < 17 || moduleDepth < 9 || moduleDepth > 18)
- throw new Error(`Template cannot fit ${f.properties.id}: ${moduleWidth.toFixed(1)}x${moduleDepth.toFixed(1)}m`)
- const roof = {
- id: f.properties.id,
- source: 'traced-roof',
- floors: f.properties.floors,
- width,
- depth,
- moduleCount,
- lon: toGeo([center.x, center.y]).lon,
- lat: toGeo([center.x, center.y]).lat,
- }
- rows.push(roof)
- for (let part = 0; part < moduleCount; part++) {
- const x = -width / 2 + 1 + moduleWidth * (part + 0.5) + gap * part
- const xy = [center.x + u[0] * x, center.y + u[1] * x]
- const cornersENU = [
- [-moduleWidth / 2, -moduleDepth / 2],
- [moduleWidth / 2, -moduleDepth / 2],
- [moduleWidth / 2, moduleDepth / 2],
- [-moduleWidth / 2, moduleDepth / 2],
- ].map(([a, b]) => [xy[0] + u[0] * a + v[0] * b, xy[1] + u[1] * a + v[1] * b])
- const ring = [
- ...cornersENU.map(toGeo).map((p) => [p.lon, p.lat]),
- [toGeo(cornersENU[0]).lon, toGeo(cornersENU[0]).lat],
- ]
- const candidate = turf.polygon([ring])
- if (!turf.booleanContains(f, candidate)) throw new Error(`Body escapes traced roof ${f.properties.id}-${part}`)
- const id = `${f.properties.id}-part${part}`
- const cols = Math.ceil(moduleWidth / 5),
- lines = Math.ceil(moduleDepth / 5)
- const sampleIndices = []
- for (let y = 0; y <= lines; y++)
- for (let k = 0; k <= cols; k++) {
- const xx = -moduleWidth / 2 + (moduleWidth * k) / cols,
- yy = -moduleDepth / 2 + (moduleDepth * y) / lines
- const p = toGeo([xy[0] + u[0] * xx + v[0] * yy, xy[1] + u[1] * xx + v[1] * yy])
- sampleIndices.push(samples.length)
- samples.push({ ...p, building: id })
- }
- modules.push({
- id,
- sourceId: f.properties.id,
- category: 'regular',
- placementSource: 'traced-roof',
- archetype: 'apartment',
- floors: f.properties.floors,
- heightSource: 'estimated',
- width: moduleWidth,
- depth: moduleDepth,
- heading: Math.atan2(u[1], u[0]),
- center: toGeo(xy),
- centerENU: [xy[0], xy[1], 0],
- footprintENU: cornersENU.map(([a, b]) => [a, b, 0]),
- sampleIndices,
- })
- }
- }
- for (let i = 0; i < modules.length; i++)
- for (let j = i + 1; j < modules.length; j++) {
- const a = modules[i],
- b = modules[j]
- const pa = turf.polygon([[...a.footprintENU.map((p) => p.slice(0, 2)), a.footprintENU[0].slice(0, 2)]])
- const pb = turf.polygon([[...b.footprintENU.map((p) => p.slice(0, 2)), b.footprintENU[0].slice(0, 2)]])
- if (turf.booleanOverlap(pa, pb)) throw new Error(`Bodies overlap ${a.id} and ${b.id}`)
- }
- const plan = {
- site: source.site,
- scope: 'one individually traced estate, not city coverage',
- source: source.source,
- origin,
- transform: Matrix4.toArray(transform),
- roofs: rows,
- modules,
- samples,
- samplesSha256: createHash('sha256').update(JSON.stringify(samples)).digest('hex'),
- exclusions: [],
- disclaimer:
- 'Illustrative model, estimated heights/facades, traced roof parallax; not surveyed geometry or for measurement/statistics/emergency decisions.',
- }
- writeFileSync(resolve(OUT, 'plan.json'), JSON.stringify(plan, null, 2))
- console.log(
- JSON.stringify({
- roofs: rows.length,
- modules: modules.length,
- samples: samples.length,
- levels: [...new Set(modules.map((m) => m.floors))],
- })
- )
|