/** * 沅陵县城「轻量体块」生成器(票 34)。 * * ⚠️ 这是**示意体块,不是实测建筑**:除 OSM 的少量真实轮廓外,其余是按真实路网/居民地面 * 程序化排布的**可复用楼房单元**。用途是让一张图**远景**看起来像一座县城(维护者 2026-09-18 * 对上一版「白色立方体 + 明显错位」的模型的替代方案:参照 GIS 数据做轻量建模, * 远景基本达标、不要求近景细节、楼房模型可复用)。 * * 地理配准**由构造保证**:原料是经纬度,生成时投影到以目标点(沅陵县城 110.39028164, * 28.4436787)为原点的 ENU 米制坐标,再写成 3D Tiles 的 `transform` —— 不存在"猜原点"。 * * 关键输入(本机): * · planet_...-shp/shpOutput/buildings.json OSM 真实轮廓(173 个,城区内最可靠的部分) * · planet_...-shp/shpOutput/roads.json OSM 路网(体块沿街排布) * · planet_...-shp/shpOutput/waterways.json 水系(沅江,体块避让) * · output/resa.json 1:5 万居民地面(建成区范围) * · cityMassing/terrain-grid.json 本地 DEM 采样网格(体块坐落到地形上) * * 用法:node tools/city-massing/build-town.mjs * 产物:/{town.glb, tileset.json, meta.json} */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' const GIS = 'D:/GIS/GIS' const OSM = join(GIS, 'planet_110.059,28.258_110.744,28.625-shp/shpOutput') const DATA_DIR = process.env.CITY_DATA_DIR || join(GIS, 'cityMassing') /** 原点 = 维护者给的目标坐标(CGCS2000/WGS-84 本项目视为一致)。 */ const ORIGIN = { lon: 110.39028164, lat: 28.4436787 } /** 出图窗口:目标点 ±0.020 经 / ±0.015 纬(约 1.9 km × 1.7 km,县城建成区尺度)。 */ const WINDOW = { lon: 0.02, lat: 0.015 } /** 楼高随离中心距离衰减:城区(≤400m)出 6–8 层,边缘出 1–2 层。 */ const CORE_RADIUS = 400 const CAP = 5000 const DEG = Math.PI / 180 const M_PER_DEG_LAT = 110540 const M_PER_DEG_LON = 111320 * Math.cos(ORIGIN.lat * DEG) const toLocal = (lon, lat) => ({ x: (lon - ORIGIN.lon) * M_PER_DEG_LON, y: (lat - ORIGIN.lat) * M_PER_DEG_LAT, }) const inWindow = (lon, lat) => Math.abs(lon - ORIGIN.lon) <= WINDOW.lon && Math.abs(lat - ORIGIN.lat) <= WINDOW.lat /** 稳定的整数哈希 → [0,1):同一输入每次生成结果一致(可复现,不是每次刷新都变样)。 */ function hash01(...nums) { let h = 2166136261 for (const n of nums) { let v = Math.round(n * 1000) | 0 for (let i = 0; i < 4; i++) { h ^= (v >> (i * 8)) & 0xff h = Math.imul(h, 16777619) } } return ((h >>> 0) % 100000) / 100000 } /** 线性插值取名次(用于从几个候选里挑一个,且分布可控)。 */ const pick = (arr, r) => arr[Math.min(arr.length - 1, Math.floor(r * arr.length))] // ---------------------------------------------------------------- 读入原料 const readJson = (p) => JSON.parse(readFileSync(p, 'utf8')) const buildings = readJson(join(OSM, 'buildings.json')).features const roads = readJson(join(OSM, 'roads.json')).features const waterways = readJson(join(OSM, 'waterways.json')).features const resa = readJson(join(GIS, 'output/resa.json')).features const terrain = JSON.parse(readFileSync(join(DATA_DIR, 'terrain-grid.json'), 'utf8')) /** 高程:网格双线性插值(缺失点回落到中心值)。 */ function elevationAt(lon, lat) { const gx = (lon - terrain.lon0) / terrain.dLon const gy = (lat - terrain.lat0) / terrain.dLat const n = terrain.n const cx = Math.max(0, Math.min(n - 1.001, gx)) const cy = Math.max(0, Math.min(n - 1.001, gy)) const x0 = Math.floor(cx), y0 = Math.floor(cy) const fx = cx - x0, fy = cy - y0 const at = (x, y) => terrain.heights[y * n + x] const corners = [at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)].map((v) => (typeof v === 'number' ? v : 111.6)) const top = corners[0] * (1 - fx) + corners[1] * fx const bottom = corners[2] * (1 - fx) + corners[3] * fx return top * (1 - fy) + bottom * fy } // ---------------------------------------------------------------- 避让与参考线 /** 水系折线(局部坐标),用于避让。 */ const waterLines = [] for (const f of waterways) { const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null if (!ring) continue const seg = [] for (const [lon, lat] of ring) { if (inWindow(lon, lat)) seg.push(toLocal(lon, lat)) else if (seg.length) { waterLines.push(seg); seg.length = 0 } } if (seg.length) waterLines.push(seg) } function distanceToWater(p) { let best = Infinity for (const line of waterLines) { for (let i = 1; i < line.length; i++) { const a = line[i - 1], b = line[i] const dx = b.x - a.x, dy = b.y - a.y const len2 = dx * dx + dy * dy || 1 let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2 t = Math.max(0, Math.min(1, t)) best = Math.min(best, Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))) } } return best } /** 居民地面(建成区)多边形,局部坐标。 */ const resaPolys = [] for (const f of resa) { const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null if (!ring || ring.length < 4) continue if (!ring.some(([lon, lat]) => inWindow(lon, lat))) continue resaPolys.push(ring.map(([lon, lat]) => toLocal(lon, lat))) } function inResa(p) { for (const ring of resaPolys) { let inside = false for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const a = ring[i], b = ring[j] if ((a.y > p.y) !== (b.y > p.y) && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-9) + a.x) inside = !inside } if (inside) return true } return false } /** 可沿街的等级(trunk/primary/... → 体块贴街);纯步道/小径不排。 */ const STREET_TYPES = new Set(['trunk', 'primary', 'secondary', 'tertiary', 'residential', 'unclassified', 'living_street']) const streetSegments = [] for (const f of roads) { const type = (f.properties && f.properties.type) || '' if (!STREET_TYPES.has(type)) continue const ring = f.geometry && f.geometry.type === 'LineString' ? f.geometry.coordinates : null if (!ring) continue for (let i = 1; i < ring.length; i++) { const [lon1, lat1] = ring[i - 1] const [lon2, lat2] = ring[i] if (!inWindow(lon1, lat1) && !inWindow(lon2, lat2)) continue streetSegments.push({ a: toLocal(lon1, lat1), b: toLocal(lon2, lat2), type }) } } // ---------------------------------------------------------------- 可复用楼房单元 /** * 三种**可复用**单元(同一种几何反复用,只是位置/朝向/高度/材质不同): * house 坡屋顶 1–2 层 · block 平顶 3–5 层 · slab 板楼 6–8 层。 */ const ARCHETYPES = { house: { w: 9, d: 7.2, floors: [1, 2], floorH: 3.2, pitched: true, walls: [0xf2efe6, 0xe8e2d4, 0xdfd8c8], roofs: [0x8c4a3a, 0x7a5140], }, block: { w: 14, d: 10, floors: [3, 5], floorH: 3.1, pitched: false, walls: [0xe9e6df, 0xd9d6cf, 0xcfd2d4], roofs: [0x6f7478, 0x5c6165], }, slab: { w: 26, d: 11, floors: [6, 8], floorH: 3.0, pitched: false, walls: [0xe4e6e8, 0xd6d9dc], roofs: [0x545a5f], }, } const UNIT_BOX = makeBox() /** 单位立方体(0..1 各轴),实例化时按尺寸缩放 —— 几何只有一份,靠矩阵复用。 */ function makeBox() { return { // 6 面 × 4 顶点 positions: [ 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, // 底 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, // 顶 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, // 南 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, // 北 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, // 西 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, // 东 ], normals: [ 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, ], indices: [ 0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22, 20, 22, 23, ], } } /** 把一栋楼推成三角面,写进按材质分组的桶里。 */ function emitBuilding(buckets, { x, y, z, heading, type, height, wallColor, roofColor, width, depth }) { const cos = Math.cos(heading), sin = Math.sin(heading) // 局部(x,y) → 世界;glTF 是 Y-up:写 (east, up, -north) const put = (lx, ly, lz) => { const ex = lx * cos - ly * sin const ny = lx * sin + ly * cos return [x + ex, z + lz, -(y + ny)] } const hw = width / 2, hd = depth / 2 const wallBucket = buckets.get(wallColor) || { positions: [], normals: [], indices: [], base: 0 } const pushQuad = (bucket, quad, normal) => { const base = bucket.positions.length / 3 for (const v of quad) bucket.positions.push(v[0], v[1], v[2]) for (let i = 0; i < 4; i++) bucket.normals.push(normal[0], normal[1], normal[2]) bucket.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) } // 四片墙(用旋转后的法线) const rot = (nx, ny) => [nx * cos - ny * sin, -(nx * sin + ny * cos)] const corners = [ put(-hw, -hd, 0), put(hw, -hd, 0), put(hw, hd, 0), put(-hw, hd, 0), ] const top = [ put(-hw, -hd, height), put(hw, -hd, height), put(hw, hd, height), put(-hw, hd, height), ] const wallNormal = (i) => { const n = [[0, -1], [1, 0], [0, 1], [-1, 0]][i] const r = rot(n[0], n[1]) return [r[0], 0, r[1]] } for (let i = 0; i < 4; i++) { const j = (i + 1) % 4 pushQuad(wallBucket, [corners[i], corners[j], top[j], top[i]], wallNormal(i)) } buckets.set(wallColor, wallBucket) if (type === 'house' && ARCHETYPES.house.pitched) { // 坡屋顶:沿长边起脊 const roofBucket = buckets.get(roofColor) || { positions: [], normals: [], indices: [], base: 0 } const ridgeH = 1.9 const r1 = put(0, -hd, height + ridgeH) const r2 = put(0, hd, height + ridgeH) pushQuad(roofBucket, [top[0], top[1], r1, r1], [0, 1, 0]) // 占位,替换如下 roofBucket.positions.length -= 12 roofBucket.normals.length -= 12 roofBucket.indices.length -= 6 const tri = (p1, p2, p3, n) => { const base = roofBucket.positions.length / 3 for (const v of [p1, p2, p3]) roofBucket.positions.push(v[0], v[1], v[2]) for (let i = 0; i < 3; i++) roofBucket.normals.push(n[0], n[1], n[2]) roofBucket.indices.push(base, base + 1, base + 2) } tri(top[0], top[1], r1, [0, 1, 0]) tri(top[1], top[3], r2, [0, 1, 0]) tri(top[1], r2, r1, [0, 1, 0]) tri(top[3], top[2], r2, [0, 1, 0]) tri(top[2], top[0], r1, [0, 1, 0]) tri(top[2], r1, r2, [0, 1, 0]) tri(top[0], top[3], r1, [0, 1, 0]) tri(top[3], r2, r1, [0, 1, 0]) buckets.set(roofColor, roofBucket) return } const roofBucket = buckets.get(roofColor) || { positions: [], normals: [], indices: [], base: 0 } pushQuad(roofBucket, [top[0], top[3], top[2], top[1]], [0, 1, 0]) buckets.set(roofColor, roofBucket) } // ---------------------------------------------------------------- 生成实例 const buckets = new Map() let placed = 0 const stats = { house: 0, block: 0, slab: 0, real: 0, skippedWater: 0 } /** 按离中心距离选单元:近中心出板楼/条楼,边缘出坡顶民居。 */ function chooseType(x, y, r) { const dist = Math.hypot(x, y) if (dist < CORE_RADIUS) return r < 0.28 ? 'slab' : 'block' if (dist < CORE_RADIUS * 2.4) return r < 0.7 ? 'block' : 'house' return r < 0.25 ? 'block' : 'house' } function emitInstance(x, y, heading, type, r) { if (placed >= CAP) return const spec = ARCHETYPES[type] const lon = ORIGIN.lon + x / M_PER_DEG_LON const lat = ORIGIN.lat + y / M_PER_DEG_LAT if (!inWindow(lon, lat)) return if (distanceToWater({ x, y }) < 26) { stats.skippedWater += 1; return } const floors = spec.floors[0] + Math.floor(r * (spec.floors[1] - spec.floors[0] + 1)) const height = floors * spec.floorH const width = spec.w * (0.85 + 0.3 * hash01(x, y, 7)) const depth = spec.d * (0.9 + 0.2 * hash01(x, y, 11)) emitBuilding(buckets, { x, y, z: elevationAt(lon, lat) - 0.6, // 略埋 0.6m,避免与地形之间露缝 heading, type, height, width, depth, wallColor: pick(spec.walls, hash01(x, y, 3)), roofColor: pick(spec.roofs, hash01(x, y, 5)), }) placed += 1 stats[type] += 1 } // 1) 真实 OSM 轮廓:能用的都用(它们的形状是真的) for (const f of buildings) { const geom = f.geometry if (!geom) continue const rings = geom.type === 'Polygon' ? [geom.coordinates[0]] : geom.type === 'MultiPolygon' ? geom.coordinates.map((p) => p[0]) : [] for (const ring of rings) { if (!ring || ring.length < 4) continue const local = ring.filter(([lon, lat]) => inWindow(lon, lat)).map(([lon, lat]) => toLocal(lon, lat)) if (local.length < 4) continue // 以轮廓中心为实例位置,按外接盒尺寸建体块(真实轮廓保留下来做形状约束太细,远景不需要) const xs = local.map((p) => p.x), ys = local.map((p) => p.y) const cx = (Math.min(...xs) + Math.max(...xs)) / 2 const cy = (Math.min(...ys) + Math.max(...ys)) / 2 const w = Math.max(6, Math.max(...xs) - Math.min(...xs)) const d = Math.max(5, Math.max(...ys) - Math.min(...ys)) const lon = ORIGIN.lon + cx / M_PER_DEG_LON const lat = ORIGIN.lat + cy / M_PER_DEG_LAT if (distanceToWater({ x: cx, y: cy }) < 22) continue const r = hash01(cx, cy, 13) // 城区真实轮廓按条楼/条楼处理 const type = w * d > 420 ? 'slab' : 'block' const spec = ARCHETYPES[type] const floors = 2 + Math.floor(r * 4) emitBuilding(buckets, { x: cx, y: cy, z: elevationAt(lon, lat) - 0.6, heading: 0, type, height: floors * spec.floorH, width: w, depth: d, wallColor: pick(spec.walls, r), roofColor: pick(spec.roofs, hash01(cx, cy, 17)), }) placed += 1 stats.real += 1 } } // 2) 沿街排布:每条可沿街的线段两侧按间距放楼(贴街是我国县城的常态) const SPACING = 23 for (const seg of streetSegments) { const dx = seg.b.x - seg.a.x, dy = seg.b.y - seg.a.y const len = Math.hypot(dx, dy) if (len < 12) continue const heading = Math.atan2(dy, dx) const steps = Math.floor(len / SPACING) for (let s = 0; s <= steps; s++) { const t = steps === 0 ? 0.5 : s / steps const px = seg.a.x + dx * t const py = seg.a.y + dy * t for (const side of [-1, 1]) { const off = 11 + 6 * hash01(px, py, side) const ox = px - Math.sin(heading) * off * side const oy = py + Math.cos(heading) * off * side const r = hash01(ox, oy, 19) emitInstance(ox, oy, heading + (r < 0.5 ? 0 : Math.PI / 2), chooseType(ox, oy, hash01(ox, oy, 23)), r) } } } // 3) 建成区补白:居民地面内做抖动网格,避免"只有街边有楼"的空旷感 const FILL = 30 for (const ring of resaPolys) { const xs = ring.map((p) => p.x), ys = ring.map((p) => p.y) for (let gx = Math.min(...xs); gx <= Math.max(...xs); gx += FILL) { for (let gy = Math.min(...ys); gy <= Math.max(...ys); gy += FILL) { const jx = gx + (hash01(gx, gy, 29) - 0.5) * FILL * 0.7 const jy = gy + (hash01(gx, gy, 31) - 0.5) * FILL * 0.7 if (!inResa({ x: jx, y: jy })) continue const r = hash01(jx, jy, 37) if (r > 0.72) continue // 留出空隙:不是每格都盖楼 emitInstance(jx, jy, (hash01(jx, jy, 41) - 0.5) * 0.6, chooseType(jx, jy, hash01(jx, jy, 43)), r) } } } // ---------------------------------------------------------------- 写 GLB function writeGlb(path, bucketList) { const json = { asset: { version: '2.0', generator: 'cockpit-city-massing' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0, name: 'yuanling-town-massing' }], meshes: [{ name: 'town', primitives: [] }], materials: [], accessors: [], bufferViews: [], buffers: [], } const bin = [] let byteOffset = 0 const pushView = (data, target) => { const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength) json.bufferViews.push({ buffer: 0, byteOffset, byteLength: buf.length, target }) bin.push(buf) byteOffset += buf.length + ((4 - (buf.length % 4)) % 4) return json.bufferViews.length - 1 } for (const [color, bucket] of bucketList) { if (!bucket.indices.length) continue const positions = new Float32Array(bucket.positions) const normals = new Float32Array(bucket.normals) const indices = new Uint32Array(bucket.indices) const min = [Infinity, Infinity, Infinity] const max = [-Infinity, -Infinity, -Infinity] for (let i = 0; i < positions.length; i += 3) { for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], positions[i + k]) max[k] = Math.max(max[k], positions[i + k]) } } const posView = pushView(positions, 34962) const norView = pushView(normals, 34962) const idxView = pushView(indices, 34963) const posAcc = json.accessors.push({ bufferView: posView, componentType: 5126, count: positions.length / 3, type: 'VEC3', min, max }) - 1 const norAcc = json.accessors.push({ bufferView: norView, componentType: 5126, count: normals.length / 3, type: 'VEC3' }) - 1 const idxAcc = json.accessors.push({ bufferView: idxView, componentType: 5125, count: indices.length, type: 'SCALAR' }) - 1 const material = json.materials.push({ pbrMetallicRoughness: { baseColorFactor: [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1], metallicFactor: 0, roughnessFactor: 0.85, }, doubleSided: false, }) - 1 json.meshes[0].primitives.push({ attributes: { POSITION: posAcc, NORMAL: norAcc }, indices: idxAcc, material, }) } const binBuffer = Buffer.concat(bin) json.buffers.push({ byteLength: binBuffer.length }) const jsonText = Buffer.from(JSON.stringify(json), 'utf8') const jsonPad = (4 - (jsonText.length % 4)) % 4 const jsonChunk = Buffer.concat([jsonText, Buffer.alloc(jsonPad, 0x20)]) const binPad = (4 - (binBuffer.length % 4)) % 4 const binChunk = Buffer.concat([binBuffer, Buffer.alloc(binPad, 0)]) const total = 12 + 8 + jsonChunk.length + 8 + binChunk.length const header = Buffer.alloc(12) header.write('glTF', 0, 'ascii') header.writeUInt32LE(2, 4) header.writeUInt32LE(total, 8) const jsonHeader = Buffer.alloc(8) jsonHeader.writeUInt32LE(jsonChunk.length, 0) jsonHeader.write('JSON', 4, 'ascii') const binHeader = Buffer.alloc(8) binHeader.writeUInt32LE(binChunk.length, 0) binHeader.write('BIN\0', 4, 'ascii') const out = Buffer.concat([header, jsonHeader, jsonChunk, binHeader, binChunk]) writeFileSync(path, out) return { bytes: out.length, primitives: json.meshes[0].primitives.length, materials: json.materials.length } } // ---------------------------------------------------------------- 写 tileset + meta const R = 6378137 const F = 1 / 298.257223563 const E2 = 2 * F - F * F function enuTransform(lon, lat, height) { const lonR = lon * DEG, latR = lat * DEG const sinLat = Math.sin(latR), cosLat = Math.cos(latR) const sinLon = Math.sin(lonR), cosLon = Math.cos(lonR) const N = R / Math.sqrt(1 - E2 * sinLat * sinLat) const ox = (N + height) * cosLat * cosLon const oy = (N + height) * cosLat * sinLon const oz = (N * (1 - E2) + height) * sinLat return [-sinLon, cosLon, 0, 0, -sinLat * cosLon, -sinLat * sinLon, cosLat, 0, cosLat * cosLon, cosLat * sinLon, sinLat, 0, ox, oy, oz, 1] } mkdirSync(DATA_DIR, { recursive: true }) const bucketList = [...buckets.entries()] const glbInfo = writeGlb(join(DATA_DIR, 'town.glb'), bucketList) // 包围盒(局部 ENU 米):从顶点直接算 let bx = [-Infinity, Infinity], by = [-Infinity, Infinity], bz = [-Infinity, Infinity] for (const [, b] of bucketList) { for (let i = 0; i < b.positions.length; i += 3) { // glTF Y-up → ENU:east=x, up=y, north=-z const east = b.positions[i], up = b.positions[i + 1], north = -b.positions[i + 2] bx = [Math.max(bx[0], east), Math.min(bx[1], east)] by = [Math.max(by[0], north), Math.min(by[1], north)] bz = [Math.max(bz[0], up), Math.min(bz[1], up)] } } const box = [ (bx[0] + bx[1]) / 2, (by[0] + by[1]) / 2, (bz[0] + bz[1]) / 2, (bx[0] - bx[1]) / 2, 0, 0, 0, (by[0] - by[1]) / 2, 0, 0, 0, (bz[0] - bz[1]) / 2, ] const tileset = { asset: { version: '1.1', generator: 'cockpit-city-massing' }, // 注意:tileset 级 geometricError 必须 > 0 —— 实测写成 0 时 Cesium 不请求根瓦片内容 // (tileset.json 拉到了、town.glb 始终不下载,页面上一栋楼都没有)。 geometricError: 800, root: { transform: enuTransform(ORIGIN.lon, ORIGIN.lat, 0), boundingVolume: { box }, // 根节点只有一块内容,不需要再细分:0 = 到此为止(refine 也就没机会生效)。 geometricError: 0, refine: 'ADD', content: { uri: 'town.glb' }, }, } writeFileSync(join(DATA_DIR, 'tileset.json'), JSON.stringify(tileset, null, 2), 'utf8') const meta = { _disclaimer: '示意体块,不是实测建筑。除 OSM 少量真实轮廓外,其余体块按真实路网/建成区程序化排布,用于远景观感;不得用于任何量测、统计或应急决策。', origin: ORIGIN, window: WINDOW, counts: { total: placed, ...stats, cap: CAP }, archetypes: Object.keys(ARCHETYPES), materials: glbInfo.materials, glbBytes: glbInfo.bytes, localBox: { x: [bx[1], bx[0]], y: [by[1], by[0]], z: [bz[1], bz[0]] }, generatedAt: new Date().toISOString(), } writeFileSync(join(DATA_DIR, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8') console.log(`生成完成:${placed} 栋(真实轮廓 ${stats.real} / 民居 ${stats.house} / 条楼 ${stats.block} / 板楼 ${stats.slab},避让水系 ${stats.skippedWater})`) console.log(`GLB:${(glbInfo.bytes / 1024).toFixed(0)} KB,${glbInfo.primitives} 个图元 / ${glbInfo.materials} 种材质`) console.log(`局部范围(米):x ${bx[1].toFixed(0)}~${bx[0].toFixed(0)},y ${by[1].toFixed(0)}~${by[0].toFixed(0)},z ${bz[1].toFixed(0)}~${bz[0].toFixed(0)}`) console.log(`产物目录:${DATA_DIR}`)