From f1ddedf88937332c94c4d72de2d6b1fc5d236f90 Mon Sep 17 00:00:00 2001 From: que01 Date: Mon, 27 Jul 2026 13:52:43 +0800 Subject: [PATCH] Add experimental Cesium vehicle cruise preview --- README.md | 6 + docs/changelog.md | 1 + scripts/build-area.js | 289 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 293 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a63038e..f7e6ed9 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,12 @@ python3 -m http.server 8765 然后打开 `http://localhost:8765/my-area-cesium-preview.html`。 +## 实验:车辆巡航 + +`preview` 和 `cesium` 阶段会额外生成 `-vehicle-route.json`。该文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。 + +这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 资产本身。 + ## 已沉淀区域 - `config/areas/nantaizi-lake-innovation-valley.json` diff --git a/docs/changelog.md b/docs/changelog.md index c4f4fd8..54dcba8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,7 @@ ## 2026-07-27 +- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `-vehicle-route.json`,并在预览页中驱动车辆循环移动。 - 将项目主入口重构为区域资产管线:`scripts/build-area.js`。 - 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。 - `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。 diff --git a/scripts/build-area.js b/scripts/build-area.js index 930791f..cd53a28 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -82,6 +82,7 @@ function normalizeAreaConfig(raw) { cesiumPreview: path.resolve( outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`), ), + vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)), pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")), }; @@ -272,13 +273,168 @@ function writeCesiumPreview(area) { ensureFile(area.outputs.metadata, "Cesium metadata"); const htmlPath = area.outputs.cesiumPreview; fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + writeVehicleRoute(area); const glbName = path.basename(area.outputs.glb); const metadataName = path.basename(area.outputs.metadata); - fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, area.id)); + const routeName = path.basename(area.outputs.vehicleRoute); + fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, area.id)); console.log(`Cesium preview: ${htmlPath}`); } -function cesiumPreviewHtml(glbName, metadataName, areaId) { +function writeVehicleRoute(area) { + const route = buildVehicleRoute(area.input); + fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true }); + fs.writeFileSync(area.outputs.vehicleRoute, `${JSON.stringify(route, null, 2)}\n`); + console.log(`Vehicle route: ${area.outputs.vehicleRoute}`); +} + +function buildVehicleRoute(osmPath) { + const xml = fs.readFileSync(osmPath, "utf8"); + const bounds = osmBounds(xml); + const nodes = new Map(); + for (const match of xml.matchAll(/]*)>/g)) { + const attrs = xmlAttrs(match[1]); + if (!attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue; + nodes.set(attrs.id, [Number(attrs.lon), Number(attrs.lat)]); + } + + const segments = []; + for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { + const attrs = xmlAttrs(match[1]); + const body = match[2]; + const tags = {}; + for (const tagMatch of body.matchAll(/]*)\/?>/g)) { + const tag = xmlAttrs(tagMatch[1]); + if (tag.k) tags[tag.k] = tag.v || ""; + } + if (!isCruiseHighway(tags)) continue; + const coords = []; + for (const ndMatch of body.matchAll(/]*)\/?>/g)) { + const nd = xmlAttrs(ndMatch[1]); + const coord = nodes.get(nd.ref); + if (coord) coords.push(coord); + } + const runs = splitInBounds(compactCoords(coords), bounds); + let runIndex = 0; + for (const run of runs) { + const lengthMeters = routeLength(run); + if (lengthMeters < 20) continue; + runIndex += 1; + segments.push({ + id: runIndex === 1 ? (attrs.id || `way-${segments.length + 1}`) : `${attrs.id || "way"}-${runIndex}`, + name: tags.name || tags.highway || "road", + highway: tags.highway || "", + oneWay: tags.oneway || "", + lengthMeters, + coordinates: run, + }); + } + } + segments.sort((a, b) => b.lengthMeters - a.lengthMeters); + return { + source: osmPath, + bounds, + generatedAt: new Date().toISOString(), + speedMetersPerSecond: 8.0, + loop: true, + segments, + }; +} + +function osmBounds(xml) { + const match = xml.match(/]*)\/?>/); + if (!match) return null; + const attrs = xmlAttrs(match[1]); + const bounds = { + minLon: Number(attrs.minlon), + minLat: Number(attrs.minlat), + maxLon: Number(attrs.maxlon), + maxLat: Number(attrs.maxlat), + }; + return Object.values(bounds).every(Number.isFinite) ? bounds : null; +} + +function xmlAttrs(text) { + const attrs = {}; + for (const match of text.matchAll(/([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) { + attrs[match[1]] = match[3] !== undefined ? match[3] : match[4]; + } + return attrs; +} + +function isCruiseHighway(tags) { + const highway = tags.highway || ""; + if (!highway) return false; + if (tags.area === "yes") return false; + const blocked = new Set([ + "footway", "path", "pedestrian", "steps", "cycleway", "service", "track", + "bridleway", "corridor", "elevator", "platform", "construction", + ]); + return !blocked.has(highway); +} + +function compactCoords(coords) { + const out = []; + for (const coord of coords) { + const last = out[out.length - 1]; + if (!last || last[0] !== coord[0] || last[1] !== coord[1]) { + out.push(coord); + } + } + return out; +} + +function splitInBounds(coords, bounds) { + if (!bounds) return [coords]; + const runs = []; + let current = []; + for (const coord of coords) { + if (insideBounds(coord, bounds)) { + current.push(coord); + } else if (current.length) { + if (current.length >= 2) runs.push(current); + current = []; + } + } + if (current.length >= 2) runs.push(current); + return runs; +} + +function insideBounds(coord, bounds) { + const pad = 0.00002; + return ( + coord[0] >= bounds.minLon - pad && + coord[0] <= bounds.maxLon + pad && + coord[1] >= bounds.minLat - pad && + coord[1] <= bounds.maxLat + pad + ); +} + +function routeLength(coords) { + let total = 0; + for (let i = 1; i < coords.length; i += 1) { + total += haversineMeters(coords[i - 1], coords[i]); + } + return total; +} + +function haversineMeters(a, b) { + const radius = 6371008.8; + const lat1 = degreesToRadians(a[1]); + const lat2 = degreesToRadians(b[1]); + const dLat = degreesToRadians(b[1] - a[1]); + const dLon = degreesToRadians(b[0] - a[0]); + const sinLat = Math.sin(dLat / 2); + const sinLon = Math.sin(dLon / 2); + const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon; + return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h))); +} + +function degreesToRadians(value) { + return value * Math.PI / 180; +} + +function cesiumPreviewHtml(glbName, metadataName, routeName, areaId) { return ` @@ -308,17 +464,52 @@ function cesiumPreviewHtml(glbName, metadataName, areaId) { font-size: 12px; line-height: 1.45; } + #controls { + position: absolute; + top: 12px; + left: 12px; + z-index: 1; + display: flex; + gap: 8px; + align-items: center; + padding: 8px; + border-radius: 4px; + background: rgba(20, 24, 28, 0.78); + color: #fff; + font-size: 12px; + } + #controls button { + height: 28px; + border: 0; + border-radius: 4px; + padding: 0 10px; + background: #f2f5f7; + color: #111; + cursor: pointer; + } + #controls input { + width: 120px; + }
+
+ + + 8 m/s +
Loading ${escapeHtml(glbName)}...