93 lines
5.1 KiB
TypeScript
93 lines
5.1 KiB
TypeScript
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(); }
|
|
}
|