Add experimental Cesium vehicle cruise preview
This commit is contained in:
@@ -114,6 +114,12 @@ python3 -m http.server 8765
|
||||
|
||||
然后打开 `http://localhost:8765/my-area-cesium-preview.html`。
|
||||
|
||||
## 实验:车辆巡航
|
||||
|
||||
`preview` 和 `cesium` 阶段会额外生成 `<area-id>-vehicle-route.json`。该文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,Cesium 预览页会加载最长道路段并显示一辆实验车辆循环巡航。
|
||||
|
||||
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 资产本身。
|
||||
|
||||
## 已沉淀区域
|
||||
|
||||
- `config/areas/nantaizi-lake-innovation-valley.json`
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 2026-07-27
|
||||
|
||||
- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-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`。
|
||||
|
||||
@@ -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(/<node\b([^>]*)>/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(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = {};
|
||||
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?>/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(/<nd\b([^>]*)\/?>/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(/<bounds\b([^>]*)\/?>/);
|
||||
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 `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="cesiumContainer"></div>
|
||||
<div id="controls">
|
||||
<button id="toggleCruise">Pause</button>
|
||||
<label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label>
|
||||
<span id="speedLabel">8 m/s</span>
|
||||
</div>
|
||||
<div id="status">Loading ${escapeHtml(glbName)}...</div>
|
||||
<script>
|
||||
const statusEl = document.getElementById("status");
|
||||
const toggleCruise = document.getElementById("toggleCruise");
|
||||
const speedControl = document.getElementById("speedControl");
|
||||
const speedLabel = document.getElementById("speedLabel");
|
||||
Cesium.Ion.defaultAccessToken = "";
|
||||
|
||||
async function main() {
|
||||
const metadata = await fetch("${escapeJs(metadataName)}").then((r) => r.json());
|
||||
const routeData = await fetch("${escapeJs(routeName)}").then((r) => r.json());
|
||||
const anchor = metadata.anchor || {};
|
||||
const longitude = Number(anchor.longitude || 0);
|
||||
const latitude = Number(anchor.latitude || 0);
|
||||
@@ -350,13 +541,105 @@ function cesiumPreviewHtml(glbName, metadataName, areaId) {
|
||||
scale: 1.0
|
||||
});
|
||||
viewer.scene.primitives.add(model);
|
||||
const cruise = addVehicleCruise(viewer, routeData);
|
||||
|
||||
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(center, 900.0),
|
||||
{ duration: 0.0 }
|
||||
);
|
||||
statusEl.textContent = "${escapeJs(glbName)} | " + longitude.toFixed(6) + ", " + latitude.toFixed(6);
|
||||
statusEl.textContent = "${escapeJs(glbName)} | route " + cruise.segment.id + " | " + Math.round(cruise.segment.lengthMeters) + "m";
|
||||
}
|
||||
|
||||
function addVehicleCruise(viewer, routeData) {
|
||||
const segment = (routeData.segments || [])[0];
|
||||
if (!segment || !segment.coordinates || segment.coordinates.length < 2) {
|
||||
throw new Error("No drivable route found in ${escapeJs(routeName)}");
|
||||
}
|
||||
const speed = Number(routeData.speedMetersPerSecond || 8);
|
||||
const start = Cesium.JulianDate.now();
|
||||
const duration = Math.max(8, segment.lengthMeters / speed);
|
||||
const stop = Cesium.JulianDate.addSeconds(start, duration, new Cesium.JulianDate());
|
||||
const positions = new Cesium.SampledPositionProperty();
|
||||
let elapsed = 0;
|
||||
let previous = segment.coordinates[0];
|
||||
positions.addSample(start, Cesium.Cartesian3.fromDegrees(previous[0], previous[1], 1.15));
|
||||
for (let i = 1; i < segment.coordinates.length; i += 1) {
|
||||
const coord = segment.coordinates[i];
|
||||
elapsed += distanceMeters(previous, coord) / speed;
|
||||
const time = Cesium.JulianDate.addSeconds(start, elapsed, new Cesium.JulianDate());
|
||||
positions.addSample(time, Cesium.Cartesian3.fromDegrees(coord[0], coord[1], 1.15));
|
||||
previous = coord;
|
||||
}
|
||||
positions.setInterpolationOptions({
|
||||
interpolationDegree: 1,
|
||||
interpolationAlgorithm: Cesium.LinearApproximation
|
||||
});
|
||||
|
||||
viewer.clock.startTime = start.clone();
|
||||
viewer.clock.stopTime = stop.clone();
|
||||
viewer.clock.currentTime = start.clone();
|
||||
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP;
|
||||
viewer.clock.multiplier = 1;
|
||||
viewer.clock.shouldAnimate = true;
|
||||
|
||||
const flat = [];
|
||||
for (const coord of segment.coordinates) {
|
||||
flat.push(coord[0], coord[1], 1.05);
|
||||
}
|
||||
viewer.entities.add({
|
||||
name: "Cruise route",
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
|
||||
width: 4,
|
||||
material: Cesium.Color.CYAN.withAlpha(0.85),
|
||||
clampToGround: false
|
||||
}
|
||||
});
|
||||
const vehicle = viewer.entities.add({
|
||||
name: "Cruise vehicle",
|
||||
availability: new Cesium.TimeIntervalCollection([
|
||||
new Cesium.TimeInterval({ start, stop })
|
||||
]),
|
||||
position: positions,
|
||||
orientation: new Cesium.VelocityOrientationProperty(positions),
|
||||
box: {
|
||||
dimensions: new Cesium.Cartesian3(4.6, 1.9, 1.5),
|
||||
material: Cesium.Color.ORANGERED,
|
||||
outline: true,
|
||||
outlineColor: Cesium.Color.WHITE
|
||||
},
|
||||
path: {
|
||||
resolution: 1,
|
||||
leadTime: 4,
|
||||
trailTime: 10,
|
||||
width: 2,
|
||||
material: Cesium.Color.ORANGE.withAlpha(0.9)
|
||||
}
|
||||
});
|
||||
toggleCruise.addEventListener("click", () => {
|
||||
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
|
||||
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
|
||||
});
|
||||
speedControl.addEventListener("input", () => {
|
||||
const value = Number(speedControl.value);
|
||||
viewer.clock.multiplier = value / speed;
|
||||
speedLabel.textContent = value + " m/s";
|
||||
});
|
||||
viewer.trackedEntity = vehicle;
|
||||
return { segment, vehicle };
|
||||
}
|
||||
|
||||
function distanceMeters(a, b) {
|
||||
const radius = 6371008.8;
|
||||
const lat1 = Cesium.Math.toRadians(a[1]);
|
||||
const lat2 = Cesium.Math.toRadians(b[1]);
|
||||
const dLat = Cesium.Math.toRadians(b[1] - a[1]);
|
||||
const dLon = Cesium.Math.toRadians(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)));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
Reference in New Issue
Block a user