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`。
|
然后打开 `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`
|
- `config/areas/nantaizi-lake-innovation-valley.json`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 2026-07-27
|
## 2026-07-27
|
||||||
|
|
||||||
|
- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-vehicle-route.json`,并在预览页中驱动车辆循环移动。
|
||||||
- 将项目主入口重构为区域资产管线:`scripts/build-area.js`。
|
- 将项目主入口重构为区域资产管线:`scripts/build-area.js`。
|
||||||
- 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。
|
- 新增 `config/areas/nantaizi-lake-innovation-valley.json` 和 `config/areas/hanyang-block.json`,支持按 OSM 输入生成独立输出目录。
|
||||||
- `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。
|
- `npm run build` 现在默认走区域资产管线;旧 QGIS 管线保留为 `npm run build:qgis`。
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ function normalizeAreaConfig(raw) {
|
|||||||
cesiumPreview: path.resolve(
|
cesiumPreview: path.resolve(
|
||||||
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
|
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")),
|
pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -272,13 +273,168 @@ function writeCesiumPreview(area) {
|
|||||||
ensureFile(area.outputs.metadata, "Cesium metadata");
|
ensureFile(area.outputs.metadata, "Cesium metadata");
|
||||||
const htmlPath = area.outputs.cesiumPreview;
|
const htmlPath = area.outputs.cesiumPreview;
|
||||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||||
|
writeVehicleRoute(area);
|
||||||
const glbName = path.basename(area.outputs.glb);
|
const glbName = path.basename(area.outputs.glb);
|
||||||
const metadataName = path.basename(area.outputs.metadata);
|
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}`);
|
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>
|
return `<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
@@ -308,17 +464,52 @@ function cesiumPreviewHtml(glbName, metadataName, areaId) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="cesiumContainer"></div>
|
<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>
|
<div id="status">Loading ${escapeHtml(glbName)}...</div>
|
||||||
<script>
|
<script>
|
||||||
const statusEl = document.getElementById("status");
|
const statusEl = document.getElementById("status");
|
||||||
|
const toggleCruise = document.getElementById("toggleCruise");
|
||||||
|
const speedControl = document.getElementById("speedControl");
|
||||||
|
const speedLabel = document.getElementById("speedLabel");
|
||||||
Cesium.Ion.defaultAccessToken = "";
|
Cesium.Ion.defaultAccessToken = "";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const metadata = await fetch("${escapeJs(metadataName)}").then((r) => r.json());
|
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 anchor = metadata.anchor || {};
|
||||||
const longitude = Number(anchor.longitude || 0);
|
const longitude = Number(anchor.longitude || 0);
|
||||||
const latitude = Number(anchor.latitude || 0);
|
const latitude = Number(anchor.latitude || 0);
|
||||||
@@ -350,13 +541,105 @@ function cesiumPreviewHtml(glbName, metadataName, areaId) {
|
|||||||
scale: 1.0
|
scale: 1.0
|
||||||
});
|
});
|
||||||
viewer.scene.primitives.add(model);
|
viewer.scene.primitives.add(model);
|
||||||
|
const cruise = addVehicleCruise(viewer, routeData);
|
||||||
|
|
||||||
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
const center = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
|
||||||
viewer.camera.flyToBoundingSphere(
|
viewer.camera.flyToBoundingSphere(
|
||||||
new Cesium.BoundingSphere(center, 900.0),
|
new Cesium.BoundingSphere(center, 900.0),
|
||||||
{ duration: 0.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) => {
|
main().catch((error) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user