| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- // Verify REAL decoded roof triangles against seated GIS footprints, not material names.
- // node tools/city-massing/verify-roof-caps.mjs <batch...> [--report <new-report.json>]
- import assert from 'node:assert/strict'
- import { readFileSync, writeFileSync } from 'node:fs'
- import { resolve, join } from 'node:path'
- import draco3d from 'draco3d'
- const args = process.argv.slice(2)
- const reportAt = args.indexOf('--report')
- const reportPath = reportAt < 0 ? null : args.splice(reportAt, 2)[1]
- assert.ok(args.length, 'one or more batch directories are required')
- const draco = await draco3d.createDecoderModule({})
- const report = { passed: false, batches: [] }
- function roofTone(id) {
- let seed = 2166136261
- for (const character of id) seed = Math.imul(seed ^ character.charCodeAt(0), 16777619) >>> 0
- return ['Roof_Slate', 'Roof_Slate_Light', 'Roof_Terracotta', 'Roof_Terra_Light'][Math.floor(seed / 4) % 4]
- }
- const area = (ring) => Math.abs(ring.reduce((sum, p, i) => {
- const q = ring[(i + 1) % ring.length]
- return sum + p[0] * q[1] - q[0] * p[1]
- }, 0) / 2)
- const perimeter = (ring) => ring.reduce((sum, p, i) => {
- const q = ring[(i + 1) % ring.length]
- return sum + Math.hypot(p[0] - q[0], p[1] - q[1])
- }, 0)
- const inside = (x, y, ring) => {
- let yes = false
- for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
- const a = ring[i], b = ring[j]
- if ((a[1] > y) !== (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0]) yes = !yes
- }
- return yes
- }
- function readGlb(file) {
- const data = readFileSync(file)
- assert.equal(data.toString('ascii', 0, 4), 'glTF', file)
- const jsonLength = data.readUInt32LE(12)
- const json = JSON.parse(data.toString('utf8', 20, 20 + jsonLength))
- const offset = 20 + jsonLength
- assert.equal(data.toString('ascii', offset + 4, offset + 8), 'BIN\0', file)
- return { json, bin: data.subarray(offset + 8) }
- }
- function roofTriangles(file) {
- const { json, bin } = readGlb(file)
- const result = []
- let reversed = 0
- const node = json.nodes.find((value) => value.mesh !== undefined)
- assert.ok(node && !node.matrix && !node.rotation && !node.scale, `${file}: unexpected model transform`)
- const translation = node.translation || [0, 0, 0]
- for (const part of json.meshes[node.mesh].primitives) {
- if (!/^(Roof_|SatelliteRoof_)/.test(json.materials[part.material]?.name || '')) continue
- const ext = part.extensions.KHR_draco_mesh_compression
- const view = json.bufferViews[ext.bufferView]
- const encoded = bin.subarray(view.byteOffset || 0, (view.byteOffset || 0) + view.byteLength)
- const decoder = new draco.Decoder(), buffer = new draco.DecoderBuffer(), mesh = new draco.Mesh()
- const allocated = []
- let status
- try {
- buffer.Init(new Int8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength), encoded.byteLength)
- status = decoder.DecodeBufferToMesh(buffer, mesh)
- assert.ok(status.ok(), `${file}: Draco decode failed`)
- const values = (semantic) => {
- const attribute = decoder.GetAttributeByUniqueId(mesh, ext.attributes[semantic])
- const bytes = mesh.num_points() * 3 * 4
- const ptr = draco._malloc(bytes); allocated.push(ptr)
- decoder.GetAttributeDataArrayForAllPoints(mesh, attribute, draco.DT_FLOAT32, bytes, ptr)
- return new Float32Array(draco.HEAPF32.buffer, ptr, mesh.num_points() * 3).slice()
- }
- const positions = values('POSITION'), normals = values('NORMAL')
- const bytes = mesh.num_faces() * 3 * 4
- const ptr = draco._malloc(bytes); allocated.push(ptr)
- decoder.GetTrianglesUInt32Array(mesh, bytes, ptr)
- const indices = new Uint32Array(draco.HEAPU32.buffer, ptr, mesh.num_faces() * 3)
- for (let i = 0; i < indices.length; i += 3) {
- const points = [indices[i], indices[i + 1], indices[i + 2]].map((index) => [
- positions[index * 3] + translation[0], -(positions[index * 3 + 2] + translation[2]),
- positions[index * 3 + 1] + translation[1]])
- const [a, b, c] = points
- const projected = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) / 2
- // Up-facing roof area in source ENU; below-ground downward faces aren't a roof.
- if (projected <= 1e-7) { if (projected < -1e-7) reversed++; continue }
- const flat = Math.max(a[2], b[2], c[2]) - Math.min(a[2], b[2], c[2]) < 0.001
- // Roof material also covers library fascia. Check vertical normals only after
- // identifying a triangle as a GIS cap, rather than judging every library detail.
- result.push({ x: (a[0] + b[0] + c[0]) / 3, y: (a[1] + b[1] + c[1]) / 3,
- z: (a[2] + b[2] + c[2]) / 3, area: projected, flat, points,
- material: json.materials[part.material].name,
- upNormal: Math.min(...[indices[i], indices[i + 1], indices[i + 2]].map((index) => normals[index * 3 + 1])) })
- }
- } finally {
- for (const ptr of allocated) draco._free(ptr)
- if (status) draco.destroy(status)
- draco.destroy(mesh); draco.destroy(buffer); draco.destroy(decoder)
- }
- }
- return { triangles: result, reversed }
- }
- function checkFile(files, buildings, level, exactFootprints) {
- const list = Array.isArray(files) ? files : [files]
- const file = list[0]
- const triangles = list.flatMap((path) => roofTriangles(path).triangles)
- const expected = buildings.filter((building) => exactFootprints || building.kind === 'massing')
- const coverage = new Map(expected.map((building) => [building.id, 0]))
- const upwardShading = new Map(expected.map((building) => [building.id, 0]))
- // Heights + bounds reject other buildings cheaply; point in surveyed ring identifies the cap.
- const targets = expected.map((building) => ({ building,
- top: building.positionENU[2] + building.heightM,
- minX: Math.min(...building.footprintENU.map((point) => point[0])),
- maxX: Math.max(...building.footprintENU.map((point) => point[0])),
- minY: Math.min(...building.footprintENU.map((point) => point[1])),
- maxY: Math.max(...building.footprintENU.map((point) => point[1])),
- }))
- for (const triangle of triangles) {
- if (!triangle.flat) continue
- const matches = targets.filter(({ building, top, minX, maxX, minY, maxY }) => (triangle.material === roofTone(building.id) || triangle.material === `SatelliteRoof_${building.id}`)
- && Math.abs(triangle.z - top) < 0.3
- && triangle.x >= minX && triangle.x <= maxX && triangle.y >= minY && triangle.y <= maxY
- && inside(triangle.x, triangle.y, building.footprintENU))
- .map((target) => ({ ...target, vertexError: Math.max(...triangle.points.map((point) => Math.min(
- ...target.building.footprintENU.map((corner) => Math.hypot(point[0] - corner[0], point[1] - corner[1]))))) }))
- .filter((target) => target.vertexError < 0.3)
- // An overlapping footprint may also contain the centroid. Cap vertices must match the GIS
- // ring, which distinguishes actual roof ownership instead of borrowing a neighbour's face.
- if (matches.length) {
- matches.sort((a, b) => a.vertexError - b.vertexError || Math.abs(a.top - triangle.z) - Math.abs(b.top - triangle.z))
- const best = matches[0]
- // A few GIS records have different IDs but exactly the same ring in a different order.
- // Preserve those existing records; verify their combined geometry against combined area.
- const signature = (target) => JSON.stringify([...target.building.footprintENU].sort((a, b) => a[0] - b[0] || a[1] - b[1]))
- const equivalent = matches.filter((target) => target.top === best.top && signature(target) === signature(best))
- for (const target of equivalent) {
- const id = target.building.id, contribution = triangle.area / equivalent.length
- coverage.set(id, coverage.get(id) + contribution)
- if (triangle.upNormal > 0.99) upwardShading.set(id, upwardShading.get(id) + contribution)
- }
- }
- }
- const missing = expected.filter((building) => coverage.get(building.id) <= 1e-7)
- assert.equal(missing.length, 0, `${file}: ${missing.length} buildings have no upward roof, first ${missing[0]?.id}`)
- let checkedArea = 0
- for (const building of expected) {
- const wanted = area(building.footprintENU), actual = coverage.get(building.id)
- const tolerance = Math.max(0.02, wanted * 0.015, perimeter(building.footprintENU) * 0.15)
- assert.ok(Math.abs(actual - wanted) <= tolerance,
- `${file}: ${building.id} roof coverage ${actual.toFixed(3)} differs from GIS area ${wanted.toFixed(3)}`)
- assert.ok(Math.abs(upwardShading.get(building.id) - wanted) <= tolerance,
- `${file}: ${building.id} upward shading does not cover its GIS roof`)
- checkedArea += actual
- }
- return { level, buildings: expected.length, roofTriangles: triangles.length, roofAreaM2: checkedArea }
- }
- for (const batch of args) {
- const dir = resolve(batch)
- const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
- const streaming = Array.isArray(manifest.files)
- const sourceDir = streaming ? resolve(dir, '..') : dir
- const tree = JSON.parse(readFileSync(join(dir, 'tileset.json'), 'utf8'))
- // Older manifests may lack plan identity; the active batch name still maps to a seated source.
- const folder = sourceDir.split(/[\\/]/).at(-1)
- const planName = manifest.provenance?.plan?.startsWith('seated-') ? manifest.provenance.plan
- : 'seated-' + folder.replace(/^lod-/, '').replace(/-(aligned|roofed2|roofed)$/, '').replace(/^batch-unfitted/, 'unfitted') + '.json'
- const pilotPlan = manifest.provenance?.planPath || (/^lod-near-estate-pilot-v/.test(folder)
- ? join(sourceDir, '..', 'near-estate-pilot-input', 'seated-plan.json') : join(sourceDir, '..', planName))
- const plan = JSON.parse(readFileSync(pilotPlan, 'utf8'))
- const byId = new Map(plan.buildings.map((building) => [building.id, building]))
- if (streaming) {
- const result = { batch, buildings: manifest.count, checkedFiles: 0, checks: { lod1: 0, lod0: 0 }, roofAreaM2: 0 }
- for (const cell of manifest.cells) {
- const buildings = cell.buildingIds.map((id) => byId.get(id))
- for (const level of ['lod1', 'lod0']) {
- const files = manifest.files.filter((file) => file.uri.startsWith(cell.id + '-p') && file.level === level)
- .map((file) => join(dir, file.uri))
- const checked = checkFile(files, buildings, level, level === 'lod1')
- result.checks[level] += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles += files.length
- }
- }
- report.batches.push(result)
- console.log(JSON.stringify(result))
- continue
- }
- const byContent = new Map(manifest.cells.map((cell) => [cell.lod0.uri, cell]))
- const result = { batch, buildings: manifest.count, checkedFiles: 0, checks: { coarse: 0, lod1: 0, lod0: 0 }, roofAreaM2: 0 }
- for (const cell of manifest.cells) {
- const buildings = cell.buildingIds.map((id) => { assert.ok(byId.has(id), `unknown GIS building ${id}`); return byId.get(id) })
- for (const level of ['lod1', 'lod0']) {
- const checked = checkFile(join(dir, cell[level].uri), buildings, level, level === 'lod1')
- result.checks[level] += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles++
- }
- }
- for (const coarse of tree.root.children) {
- const buildings = coarse.children.flatMap((child) => byContent.get(child.content.uri).buildingIds).map((id) => byId.get(id))
- const checked = checkFile(join(dir, coarse.content.uri), buildings, 'coarse', true)
- result.checks.coarse += checked.buildings; result.roofAreaM2 += checked.roofAreaM2; result.checkedFiles++
- }
- report.batches.push(result)
- console.log(JSON.stringify(result))
- }
- report.passed = true
- if (reportPath) writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n')
- console.log(JSON.stringify({ passed: true, batches: report.batches.length,
- files: report.batches.reduce((sum, batch) => sum + batch.checkedFiles, 0),
- roofChecks: report.batches.reduce((sum, batch) => sum + Object.values(batch.checks).reduce((a, b) => a + b, 0), 0) }))
|