| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536 |
- /**
- * 沅陵县城「轻量体块」生成器(票 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
- * 产物:<DATA_DIR>/{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}`)
|