import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { gzipSync, gunzipSync } from 'node:zlib'; import { Mesh } from 'three'; import { createBuildings } from '../src/scene/buildings.ts'; import { BUILDINGS } from '../src/constants/buildings.ts'; import type { BuildingAssetManifest } from '../src/scene/buildingAssets.ts'; import type { BuildingHeightSource } from '../src/scene/buildingScene.ts'; const root = new URL('../', import.meta.url); const file = (path: string) => new URL(path, root); const hash = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex'); const inputs = [ 'public/buildings/gwacheon.geojson', 'public/terrain/gwanaksan.json', 'public/terrain/gwanaksan.f32', 'src/scene/buildings.ts', 'src/scene/buildingLightSpill.ts', 'src/scene/buildingScene.ts', 'src/scene/buildingAssets.ts', 'src/scene/structures.ts', 'src/scene/terrainGeometry.ts', 'src/constants/buildings.ts', 'src/constants/terrain.ts', 'package-lock.json', 'scripts/prepare-building-assets.ts', 'src/controls/summitCoordinates.ts', ]; const sourceDigest = createHash('sha256'); for (const path of inputs) { const bytes = readFileSync(file(path)); // Checkouts may convert LF/CRLF without changing generation inputs. sourceDigest.update(path).update('\0').update(path.endsWith('.f32') ? bytes : bytes.toString('utf8').replace(/\r\n/g, '\n')); } const sourceHash = sourceDigest.digest('hex'); const manifestPath = file(`public${BUILDINGS.assetManifestUrl}`); const binaryPath = file(`public${BUILDINGS.assetBufferUrl}`); const gzipPath = file(`public${BUILDINGS.assetBufferUrl}.gz`); if (process.argv.includes('--check')) { try { const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as BuildingAssetManifest; const buffer = readFileSync(binaryPath); if (manifest.version !== BUILDINGS.assetVersion || manifest.sourceHash !== sourceHash || manifest.byteLength !== buffer.byteLength || manifest.bufferHash !== hash(buffer)) throw new Error('stale'); if (!gunzipSync(readFileSync(gzipPath)).equals(buffer)) throw new Error('stale gzip'); console.log('Prebuilt buildings are up to date.'); } catch { console.error('건물 사전 생성 파일이 없거나 오래되었습니다. npm run prepare:buildings 를 실행해 주세요.'); process.exitCode = 1; } } else { const metadata = JSON.parse(readFileSync(file('public/terrain/gwanaksan.json'), 'utf8')); const heights = readFileSync(file('public/terrain/gwanaksan.f32')); const data = JSON.parse(readFileSync(file(`public${BUILDINGS.url}`), 'utf8')); const city = createBuildings(data, { metadata, heights: new Float32Array(heights.buffer, heights.byteOffset, heights.byteLength / 4) }); try { const chunks: Buffer[] = []; let byteLength = 0; const append = (array: ArrayBufferView) => { const padding = (4 - byteLength % 4) % 4; if (padding) { chunks.push(Buffer.alloc(padding)); byteLength += padding; } const offset = byteLength; const bytes = Buffer.from(array.buffer, array.byteOffset, array.byteLength); chunks.push(bytes); byteLength += bytes.byteLength; return offset; }; const meshes: BuildingAssetManifest['meshes'] = city.group.children.map(child => { const mesh = child as Mesh, geometry = mesh.geometry; geometry.computeBoundingBox(); geometry.computeBoundingSphere(); const box = geometry.boundingBox!, sphere = geometry.boundingSphere!; return { source: mesh.name.replace('building-height-', '') as BuildingHeightSource, vertices: geometry.getAttribute('position').count, position: append(geometry.getAttribute('position').array), normal: append(geometry.getAttribute('normal').array), lightHeight: append(geometry.getAttribute('buildingLightHeight').array), index: append(geometry.index!.array), indexCount: geometry.index!.count, indexType: geometry.index!.array instanceof Uint32Array ? 'uint32' : 'uint16', box: [...box.min.toArray(), ...box.max.toArray()], sphere: [...sphere.center.toArray(), sphere.radius], }; }); const spill = { offset: append(city.spill.texture.image.data), size: city.spill.texture.image.width, bounds: city.spill.bounds.toArray() as [number, number, number, number] }; const buffer = Buffer.concat(chunks); const manifest: BuildingAssetManifest = { version: BUILDINGS.assetVersion, sourceHash, bufferHash: hash(buffer), byteLength, terrain: { origin: metadata.origin, observerSurfaceElevation: metadata.observerSurfaceElevation }, counts: city.counts, target: city.target.toArray(), meshes, spill, }; writeFileSync(binaryPath, buffer); const compressed = gzipSync(buffer, { level: 9 }); writeFileSync(gzipPath, compressed); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); console.log(`Saved ${data.features.length} buildings: ${byteLength.toLocaleString()} bytes (including light map).`); console.log(`Gzip transfer: ${compressed.byteLength.toLocaleString()} bytes.`); console.log(fileURLToPath(manifestPath)); } finally { city.dispose(); } }