// 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))], }) )