// Acceptance for the CAD-driven BIM: geometry provenance, scene bounds and export accounting. import { readFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' import { Cartesian3, Matrix4, Cartographic } from 'cesium' import { PRIMARY_AREA_BOUNDS } from './primary-area.mjs' const dir = resolve('tools/city-massing/city/cad-bim') const seated = JSON.parse(readFileSync(resolve(dir, 'seated-plan.json'), 'utf8')) const previewDir = resolve(dir, 'lod-preview') const failures = [] if (!existsSync(resolve(previewDir, 'manifest.json'))) { console.log(JSON.stringify({ passed: false, reason: 'no exported manifest yet', expected: resolve(previewDir, 'manifest.json') }, null, 1)) process.exit(1) } const manifest = JSON.parse(readFileSync(resolve(previewDir, 'manifest.json'), 'utf8')) const tileset = JSON.parse(readFileSync(resolve(previewDir, 'tileset.json'), 'utf8')) // 1. Root anchor must be a valid GLB, otherwise Cesium skips the tree. const anchorUri = tileset.root?.content?.uri if (!anchorUri) failures.push('root has no content anchor') else { const anchor = readFileSync(resolve(previewDir, anchorUri)) if (anchor.toString('ascii', 0, 4) !== 'glTF' || anchor.readUInt32LE(4) !== 2 || anchor.readUInt32LE(8) !== anchor.length || anchor.toString('ascii', 16, 20) !== 'JSON') failures.push('root anchor GLB header is invalid') else { try { JSON.parse(anchor.toString('utf8', 20, 20 + anchor.readUInt32LE(12))) } catch { failures.push('root anchor GLB JSON chunk is invalid') } } } // 2. Every placed building must trace back to a seated building. const byId = new Map(seated.buildings.map((b) => [b.id, b])) const placed = new Set(manifest.cells.flatMap((cell) => cell.buildingIds)) const orphans = [...placed].filter((id) => !byId.has(id)) if (orphans.length) failures.push(`${orphans.length} placed ids have no seated building (first: ${orphans[0]})`) // 3. The primary area as an ENU box; footprints are stored in ENU metres. const inverse = Matrix4.inverseTransformation(Matrix4.fromArray(seated.transform), new Matrix4()) const corners = [ [PRIMARY_AREA_BOUNDS[0], PRIMARY_AREA_BOUNDS[1]], [PRIMARY_AREA_BOUNDS[2], PRIMARY_AREA_BOUNDS[1]], [PRIMARY_AREA_BOUNDS[2], PRIMARY_AREA_BOUNDS[3]], [PRIMARY_AREA_BOUNDS[0], PRIMARY_AREA_BOUNDS[3]], ].map(([lon, lat]) => Matrix4.multiplyByPoint(inverse, Cartesian3.fromDegrees(lon, lat), new Cartesian3())) const box = { minX: Math.min(...corners.map((c) => c.x)), maxX: Math.max(...corners.map((c) => c.x)), minY: Math.min(...corners.map((c) => c.y)), maxY: Math.max(...corners.map((c) => c.y)), } let outside = 0 for (const building of seated.buildings) { for (const [x, y] of building.footprintENU) { if (x < box.minX - 0.5 || x > box.maxX + 0.5 || y < box.minY - 0.5 || y > box.maxY + 0.5) { outside++ if (outside <= 3) failures.push(`${building.id} footprint vertex outside the primary area`) break } } } // 4. Geometry sanity: massing needs a real outline, heights must be usable. let degenerateOutlines = 0 let badHeights = 0 for (const building of seated.buildings) { const ring = building.footprintENU if (building.kind === 'massing' && (!Array.isArray(ring) || ring.length < 3)) degenerateOutlines++ if (!(building.heightM > 0) || !Number.isFinite(building.positionENU?.[2]) || !Number.isFinite(building.foundationBottom)) badHeights++ } if (degenerateOutlines) failures.push(`${degenerateOutlines} massing buildings have fewer than 3 outline points`) if (badHeights) failures.push(`${badHeights} buildings have unusable height or elevation`) const byTier = {} const byKind = {} for (const building of seated.buildings) { byTier[building.tier] = (byTier[building.tier] || 0) + 1 byKind[building.kind] = (byKind[building.kind] || 0) + 1 } const report = { passed: failures.length === 0, source: seated.source, seated: seated.buildings.length, byTier, byKind, construction: seated.construction, excludedRelief: (seated.excluded || []).filter((e) => e.relief !== undefined).length, exported: { placed: placed.size, cells: manifest.cellCount, coarseCells: manifest.hierarchy?.coarseCellCount ?? 0, lod1Bytes: manifest.totals?.lod1?.bytes ?? null, lod0Bytes: manifest.totals?.lod0?.bytes ?? null, lod1Triangles: manifest.totals?.lod1?.triangles ?? null, lod0Triangles: manifest.totals?.lod0?.triangles ?? null, }, outsidePrimaryArea: outside, failures: failures.slice(0, 15), } console.log(JSON.stringify(report, null, 1)) if (failures.length) process.exitCode = 1