| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- /**
- * 沅陵县城「轻量体块」生成器 · 第二版(票 35,替代票 34 被否的第一版)。
- *
- * ⚠️ 示意体块,不是实测建筑:除 OSM 真实轮廓外,其余按真实路网/建成区程序化排布。
- * 产物 meta.json/README 均显著标注;不得用于量测、统计或应急决策。
- *
- * 对第一版三条否决理由的修复(票 34 Answer 记录的根因):
- * ① 漏顶 → 所有三角面做**程序化定向**(按「面重心 − 体中心」翻正绕序),
- * 且材质 doubleSided —— 从机制上消灭"背面被剔除"整类漏面;
- * ② 尺寸不搭边 → 单元尺寸**从 74 个真实轮廓的分位数推导**(长边中位 53m/p90 83m、
- * 短边 27–45m),第一版的 9×7/14×10/26×11 全部过小;
- * ③ 不贴合地形 → 高程网格 17×17 → **64×64**(~27m),并**逐栋取足印四角采样**:
- * 基准 = min(四角) − 裙边 4m,楼体向下延伸埋入地下 ⇒ 数学上不悬空,
- * 正确落位后地形自然遮挡("模型压在 Cesium 之上"随之消失)。
- * 中近景可辨 → 楼层色带(每层一圈略深的墙)+ 平顶女儿墙 / 坡屋顶出檐。
- *
- * 地理配准由构造保证(同第一版):经纬度 → 目标点 ENU → 3D Tiles transform。
- * 用法:node tools/city-massing/build-town.mjs
- */
- import { readFileSync, writeFileSync, mkdirSync, readdirSync } 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')
- const ORIGIN = { lon: 110.39028164, lat: 28.4436787 }
- const WINDOW = { lon: 0.02, lat: 0.015 }
- const CORE_RADIUS = 500
- const CAP = 5000
- const SKIRT = 4 // 每栋向下多埋 4m:斜坡上不悬空、不露缝
- 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
- 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'))
- /** 高程:64×64 网格双线性插值(缺失点回落到中心值)。 */
- 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 c = [at(x0, y0), at(x0 + 1, y0), at(x0, y0 + 1), at(x0 + 1, y0 + 1)].map((v) => (typeof v === 'number' ? v : 111.6))
- return (c[0] * (1 - fx) + c[1] * fx) * (1 - fy) + (c[2] * (1 - fx) + c[3] * fx) * 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 = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2))
- 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
- }
- 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) })
- }
- }
- // ---------------------------------------------------------------- 可复用单元(尺寸来自真实轮廓分位数)
- /**
- * 单元尺寸依据(tools 脚本对 74 个真实轮廓的实测分位数):
- * 长边 中位 53m / p90 83m;短边 p25–p90 27–45m;面积 1000–3800㎡。
- * 据此(乘以 0.45 的体块系数 —— 体块只求轮廓感,不必占满整个真实地块):
- * house 长边 24–34m · block 长边 34–60m · slab 长边 60–84m
- */
- const ARCHETYPES = {
- house: { long: [24, 34], short: [13, 18], floors: [2, 3], floorH: 3.1, pitched: true },
- block: { long: [34, 60], short: [16, 24], floors: [3, 6], floorH: 3.1, pitched: false },
- slab: { long: [60, 84], short: [22, 34], floors: [6, 11], floorH: 3.0, pitched: false },
- }
- // 墙色带:墙体主色 + 楼层带更深一档(中近景读出"层");屋色独立。
- const WALLS = [
- { wall: 0xf1ede3, band: 0xd9d2c2, roof: 0x8c4a3a },
- { wall: 0xe8e4da, band: 0xcfc9bb, roof: 0x6f7478 },
- { wall: 0xdfe1e3, band: 0xc4c8cb, roof: 0x545a5f },
- { wall: 0xe3d9cd, band: 0xcabfae, roof: 0x7a5140 },
- ]
- // ---------------------------------------------------------------- 几何发射(定向 pass 消灭漏面)
- const buckets = new Map()
- let placed = 0
- const stats = { house: 0, block: 0, slab: 0, real: 0, skippedWater: 0 }
- /** 回验样本:每栋楼的位置与基准高程(供 verify 脚本独立复核"逐栋贴地")。 */
- const verificationSample = []
- function bucket(color) {
- if (!buckets.has(color)) buckets.set(color, { positions: [], normals: [], indices: [] })
- return buckets.get(color)
- }
- /** 把一个世界坐标三角形写入桶:**自动定向** —— 法线取"远离体中心"的方向,绕序随法线生成。
- * 第一版"漏顶"的根因是手写绕序出错 + 单面材质;这里从机制上保证每个面朝外。 */
- function emitTri(b, p1, p2, p3, center) {
- // 面法线
- const ux = p2[0] - p1[0], uy = p2[1] - p1[1], uz = p2[2] - p1[2]
- const vx = p3[0] - p1[0], vy = p3[1] - p1[1], vz = p3[2] - p1[2]
- let nx = uy * vz - uz * vy
- let ny = uz * vx - ux * vz
- let nz = ux * vy - uy * vx
- const len = Math.hypot(nx, ny, nz) || 1
- nx /= len; ny /= len; nz /= len
- // 面重心指向"体中心反方向"即为朝外;若与法线同向则翻转(含绕序交换)
- const gx = (p1[0] + p2[0] + p3[0]) / 3 - center[0]
- const gy = (p1[1] + p2[1] + p3[1]) / 3 - center[1]
- const gz = (p1[2] + p2[2] + p3[2]) / 3 - center[2]
- const outward = nx * gx + ny * gy + nz * gz
- // outward = 法线 · (重心 − 体中心):朝外的面这个点积应为**正**;
- // 第一版把翻转条件写反(>0 才翻),恰好一半面朝内 —— 这就是"漏顶"的机制根源。
- const flip = outward < 0
- const a = flip ? p3 : p1
- const bb = flip ? p2 : p2
- const c = flip ? p1 : p3
- const base = b.positions.length / 3
- for (const v of [a, bb, c]) b.positions.push(v[0], v[1], v[2])
- for (let i = 0; i < 3; i++) {
- const s = flip ? -1 : 1
- b.normals.push(nx * s, ny * s, nz * s)
- }
- b.indices.push(base, base + 1, base + 2)
- }
- function emitBuilding({ x, y, zBase, heading, type, floors, width, depth, palette }) {
- const spec = ARCHETYPES[type]
- const height = floors * spec.floorH
- const cos = Math.cos(heading), sin = Math.sin(heading)
- // 体中心(用于面定向;z 取半高)
- const center = [x, zBase + height / 2, -y]
- const put = (lx, ly, lz) => {
- const ex = lx * cos - ly * sin
- const ny = lx * sin + ly * cos
- return [x + ex, zBase + lz, -(y + ny)]
- }
- const hw = width / 2, hd = depth / 2
- const b = bucket(palette.wall)
- 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)]
- // 四片墙
- for (let i = 0; i < 4; i++) {
- const j = (i + 1) % 4
- emitTri(b, corners[i], corners[j], top[j], center)
- emitTri(b, corners[i], top[j], top[i], center)
- }
- // 楼层色带:每层在墙面贴一圈更深的带(外凸 0.15m),中近景读出"层"
- const band = bucket(palette.band)
- for (let f = 1; f < floors; f++) {
- const z0 = f * spec.floorH - 0.35
- const z1 = f * spec.floorH - 0.05
- const b0 = [put(-hw - 0.15, -hd - 0.15, z0), put(hw + 0.15, -hd - 0.15, z0), put(hw + 0.15, hd + 0.15, z0), put(-hw - 0.15, hd + 0.15, z0)]
- const b1 = [put(-hw - 0.15, -hd - 0.15, z1), put(hw + 0.15, -hd - 0.15, z1), put(hw + 0.15, hd + 0.15, z1), put(-hw - 0.15, hd + 0.15, z1)]
- for (let i = 0; i < 4; i++) {
- const j = (i + 1) % 4
- emitTri(band, b0[i], b0[j], b1[j], center)
- emitTri(band, b0[i], b1[j], b1[i], center)
- }
- }
- // 屋顶
- const roof = bucket(palette.roof)
- if (spec.pitched) {
- const ridge = height + Math.min(3.2, width * 0.14)
- const r1 = put(0, -hd, ridge)
- const r2 = put(0, hd, ridge)
- // 出檐 0.5m
- const e = 0.5
- const eave = [put(-hw - e, -hd - e, height - 0.1), put(hw + e, -hd - e, height - 0.1), put(hw + e, hd + e, height - 0.1), put(-hw - e, hd + e, height - 0.1)]
- const slope = [0, 0, 0]
- slope[0] = 0
- slope[1] = Math.sin(Math.atan2(ridge - height, hw))
- slope[2] = -Math.cos(Math.atan2(ridge - height, hw))
- emitTri(roof, eave[0], eave[1], r1, center)
- emitTri(roof, eave[1], eave[2], r2, center)
- emitTri(roof, eave[2], eave[3], r2, center)
- emitTri(roof, eave[3], eave[0], r1, center)
- // 山墙两片
- emitTri(roof, eave[0], r1, r2, center)
- emitTri(roof, eave[2], r2, r1, center)
- void slope
- } else {
- // 平顶 + 女儿墙(高出屋面 0.6m、内收 0.4m),远景读出"楼"而不是"盒盖"
- emitQuadTop(roof, top, center)
- const p0 = [put(-hw + 0.4, -hd + 0.4, height + 0.6), put(hw - 0.4, -hd + 0.4, height + 0.6), put(hw - 0.4, hd + 0.4, height + 0.6), put(-hw + 0.4, hd + 0.4, height + 0.6)]
- const p1 = [put(-hw, -hd, height), put(hw, -hd, height), put(hw, hd, height), put(-hw, hd, height)]
- for (let i = 0; i < 4; i++) {
- const j = (i + 1) % 4
- emitTri(roof, p1[i], p1[j], p0[j], center)
- emitTri(roof, p1[i], p0[j], p0[i], center)
- }
- }
- placed += 1
- stats[type] += 1
- }
- /** 顶面四边形(绕序按定向 pass 自动处理)。 */
- function emitQuadTop(b, top, center) {
- emitTri(b, top[0], top[1], top[2], center)
- emitTri(b, top[0], top[2], top[3], center)
- }
- // ---------------------------------------------------------------- 布点
- function chooseType(x, y, r) {
- const dist = Math.hypot(x, y)
- if (dist < CORE_RADIUS) return r < 0.3 ? 'slab' : 'block'
- if (dist < CORE_RADIUS * 2.4) return r < 0.65 ? 'block' : 'house'
- return r < 0.2 ? 'block' : 'house'
- }
- function emitInstance(x, y, heading, type, r) {
- if (placed >= CAP) return
- 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 }) < 28) { stats.skippedWater += 1; return }
- const spec = ARCHETYPES[type]
- const long = spec.long[0] + (spec.long[1] - spec.long[0]) * hash01(x, y, 7)
- const short = spec.short[0] + (spec.short[1] - spec.short[0]) * hash01(x, y, 11)
- const floors = spec.floors[0] + Math.floor(hash01(x, y, 13) * (spec.floors[1] - spec.floors[0] + 1))
- // 足印四角采地形,取最小值 - 裙边 ⇒ 不悬空(楼体下延埋入地下)
- const cornersLonLat = [
- [lon - long / 2 / M_PER_DEG_LON, lat - short / 2 / M_PER_DEG_LAT],
- [lon + long / 2 / M_PER_DEG_LON, lat - short / 2 / M_PER_DEG_LAT],
- [lon + long / 2 / M_PER_DEG_LON, lat + short / 2 / M_PER_DEG_LAT],
- [lon - long / 2 / M_PER_DEG_LON, lat + short / 2 / M_PER_DEG_LAT],
- ].map(([a, b]) => elevationAt(a, b))
- const zBase = Math.min(...cornersLonLat) - SKIRT
- verificationSample.push({ lon, lat, zBase: Math.round(zBase * 100) / 100, type })
- emitBuilding({
- x, y, zBase, heading,
- type, floors,
- width: heading % (Math.PI) < Math.PI / 2 ? long : short, // 长边沿街
- depth: heading % Math.PI < Math.PI / 2 ? short : long,
- palette: pick(WALLS, hash01(x, y, 3)),
- })
- }
- // 1) 真实 OSM 轮廓
- for (const f of buildings) {
- const ring = f.geometry && f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : null
- if (!ring) continue
- const pts = ring.filter(([lon, lat]) => inWindow(lon, lat))
- if (pts.length < 4) continue
- const ex = pts.map(([lon]) => (lon - ORIGIN.lon) * M_PER_DEG_LON)
- const ny = pts.map(([, lat]) => (lat - ORIGIN.lat) * M_PER_DEG_LAT)
- const minX = Math.min(...ex), maxX = Math.max(...ex)
- const minY = Math.min(...ny), maxY = Math.max(...ny)
- const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2
- const w = Math.max(12, maxX - minX), d = Math.max(10, maxY - minY)
- const lon = ORIGIN.lon + cx / M_PER_DEG_LON
- const lat = ORIGIN.lat + cy / M_PER_DEG_LAT
- if (distanceToWater({ x: cx, y: cy }) < 24) continue
- const r = hash01(cx, cy, 13)
- const area = w * d
- const type = area > 3200 ? 'slab' : area > 1200 ? 'block' : 'house'
- const spec = ARCHETYPES[type]
- const floors = 2 + Math.floor(r * 5)
- const cornerEls = [
- elevationAt(ORIGIN.lon + minX / M_PER_DEG_LON, ORIGIN.lat + minY / M_PER_DEG_LAT),
- elevationAt(ORIGIN.lon + maxX / M_PER_DEG_LON, ORIGIN.lat + minY / M_PER_DEG_LAT),
- elevationAt(ORIGIN.lon + maxX / M_PER_DEG_LON, ORIGIN.lat + maxY / M_PER_DEG_LAT),
- elevationAt(ORIGIN.lon + minX / M_PER_DEG_LON, ORIGIN.lat + maxY / M_PER_DEG_LAT),
- ]
- emitBuilding({
- x: cx, y: cy, zBase: Math.min(...cornerEls) - SKIRT,
- heading: 0, type, floors, width: w, depth: d,
- palette: pick(WALLS, r),
- })
- verificationSample.push({ lon, lat, zBase: Math.round((Math.min(...cornerEls) - SKIRT) * 100) / 100, type })
- placed += 1
- stats.real += 1
- }
- // 2) 沿街排布(长边沿街)
- const SPACING = 34
- 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 < 14) 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 = 16 + 10 * 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, chooseType(ox, oy, hash01(ox, oy, 23)), r)
- }
- }
- }
- // 3) 建成区补白(朝向跟最近的路向难以一一对应,这里给小角度扰动)
- const FILL = 52
- 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.7) continue
- emitInstance(jx, jy, (hash01(jx, jy, 41) - 0.5) * 0.7, chooseType(jx, jy, hash01(jx, jy, 43)), r)
- }
- }
- }
- // ---------------------------------------------------------------- 写 GLB(含定向复查)
- function writeGlb(path, bucketList) {
- const json = {
- asset: { version: '2.0', generator: 'cockpit-city-massing-v2' },
- 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, b] of bucketList) {
- if (!b.indices.length) continue
- const positions = new Float32Array(b.positions)
- const normals = new Float32Array(b.normals)
- const indices = new Uint32Array(b.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: true, // 票 35:配合定向 pass,杜绝任何"漏面"
- }) - 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 jsonChunk = Buffer.concat([jsonText, Buffer.alloc((4 - (jsonText.length % 4)) % 4, 0x20)])
- const binChunk = Buffer.concat([binBuffer, Buffer.alloc((4 - (binBuffer.length % 4)) % 4, 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')
- writeFileSync(path, Buffer.concat([header, jsonHeader, jsonChunk, binHeader, binChunk]))
- return { bytes: total, 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)
- // 包围盒
- 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) {
- 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-v2' },
- geometricError: 800, // 必须 > 0:票 34 实测 0 时 Cesium 不请求根瓦片内容
- root: {
- transform: enuTransform(ORIGIN.lon, ORIGIN.lat, 0),
- boundingVolume: { box },
- geometricError: 0,
- refine: 'ADD',
- content: { uri: 'town.glb' },
- },
- }
- writeFileSync(join(DATA_DIR, 'tileset.json'), JSON.stringify(tileset, null, 2), 'utf8')
- const meta = {
- _disclaimer: '示意体块,不是实测建筑。除 OSM 少量真实轮廓外,其余体块按真实路网/建成区程序化排布;不得用于量测、统计或应急决策。',
- _v2_changes: {
- 尺寸依据: '74 个真实轮廓分位数(长边中位 53m/p90 83m,短边 27-45m),单元=真实尺寸×0.45 体块系数',
- 贴地: '高程网格 64×64 + 逐栋足印四角采样,基准=min-4m 裙边(楼体向下延伸,不悬空)',
- 漏顶: '所有三角面程序化定向(法线取远离体中心方向)+ 材质 doubleSided',
- 中近景: '楼层色带(每层外凸 0.15m 深色带)+ 平顶女儿墙 / 坡屋顶出檐',
- },
- origin: ORIGIN,
- window: WINDOW,
- counts: { total: placed, ...stats, cap: CAP },
- archetypes: Object.fromEntries(Object.entries(ARCHETYPES).map(([k, v]) => [k, { long: v.long, short: v.short, floors: v.floors }])),
- glbBytes: glbInfo.bytes,
- localBox: { x: [bx[1], bx[0]], y: [by[1], by[0]], z: [bz[1], bz[0]] },
- // 回验样本:让 verify 脚本能独立复核"逐栋贴地"(重新按足印四角对高程网格插值)
- verificationSample: verificationSample.slice(0, 300),
- 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 / 1024).toFixed(2)} MB,${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)}`)
|