Add multi-vehicle cruise selection preview

This commit is contained in:
2026-07-27 15:55:29 +08:00
parent 4fc9397cec
commit 694f18826a
3 changed files with 138 additions and 66 deletions

View File

@@ -116,7 +116,7 @@ python3 -m http.server 8765
## 实验:车辆巡航 ## 实验:车辆巡航
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线避免车辆压道路中心线。Cesium 预览页会加载最长道路段并显示辆实验车辆循环巡航。 `preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线避免车辆压道路中心线。Cesium 预览页会加载多条道路段并显示辆实验车辆循环巡航`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。 这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。

View File

@@ -4,6 +4,7 @@
- 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-vehicle-route.json`,并在预览页中驱动车辆循环移动。 - 实验分支新增 Cesium 车辆巡航预览:从 OSM 可行驶 `highway` 提取 bounds 内路线,输出 `<area-id>-vehicle-route.json`,并在预览页中驱动车辆循环移动。
- 巡航路线从道路中心线向右偏移约 1.3 米,车辆模型改为无 logo 的轻量预览车。 - 巡航路线从道路中心线向右偏移约 1.3 米,车辆模型改为无 logo 的轻量预览车。
- 车辆巡航预览支持多辆车同时行驶,并通过 `Vehicle` 下拉框选择 Follow 目标。
- 将项目主入口重构为区域资产管线:`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`

View File

@@ -661,6 +661,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #d9e0e2;
} }
#status { #status {
position: absolute; position: absolute;
@@ -708,6 +709,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
<div id="controls"> <div id="controls">
<button id="toggleCruise">Pause</button> <button id="toggleCruise">Pause</button>
<button id="toggleFollow">Follow</button> <button id="toggleFollow">Follow</button>
<label>Vehicle <select id="vehicleSelect"></select></label>
<label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label> <label>Speed <input id="speedControl" type="range" min="2" max="22" step="1" value="8"></label>
<span id="speedLabel">8 m/s</span> <span id="speedLabel">8 m/s</span>
</div> </div>
@@ -716,6 +718,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const toggleCruise = document.getElementById("toggleCruise"); const toggleCruise = document.getElementById("toggleCruise");
const toggleFollow = document.getElementById("toggleFollow"); const toggleFollow = document.getElementById("toggleFollow");
const vehicleSelect = document.getElementById("vehicleSelect");
const speedControl = document.getElementById("speedControl"); const speedControl = document.getElementById("speedControl");
const speedLabel = document.getElementById("speedLabel"); const speedLabel = document.getElementById("speedLabel");
Cesium.Ion.defaultAccessToken = ""; Cesium.Ion.defaultAccessToken = "";
@@ -743,6 +746,12 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
baseLayer: false baseLayer: false
}); });
viewer.scene.globe.depthTestAgainstTerrain = true; viewer.scene.globe.depthTestAgainstTerrain = true;
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#d9e0e2");
viewer.scene.skyAtmosphere.show = false;
viewer.scene.skyBox.show = false;
viewer.scene.sun.show = false;
viewer.scene.moon.show = false;
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#d4dcde");
const p = Cesium.Cartesian3.fromDegrees(longitude, latitude, height); const p = Cesium.Cartesian3.fromDegrees(longitude, latitude, height);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p); const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);
@@ -754,83 +763,42 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
scale: 1.0 scale: 1.0
}); });
viewer.scene.primitives.add(model); viewer.scene.primitives.add(model);
const cruise = addVehicleCruise(viewer, routeData); const cruise = addVehicleCruises(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)} | route " + cruise.segment.id + " | " + Math.round(cruise.segment.lengthMeters) + "m"; statusEl.textContent = "${escapeJs(glbName)} | vehicles " + cruise.vehicles.length;
} }
function addVehicleCruise(viewer, routeData) { function addVehicleCruises(viewer, routeData) {
const segment = (routeData.segments || [])[0]; const segments = (routeData.segments || [])
if (!segment || !segment.coordinates || segment.coordinates.length < 2) { .filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
.slice(0, 5);
if (!segments.length) {
throw new Error("No drivable route found in ${escapeJs(routeName)}"); throw new Error("No drivable route found in ${escapeJs(routeName)}");
} }
const speed = Number(routeData.speedMetersPerSecond || 8); const speed = Number(routeData.speedMetersPerSecond || 8);
const start = Cesium.JulianDate.now(); 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.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = start.clone(); viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
viewer.clock.multiplier = 1; viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = true; viewer.clock.shouldAnimate = true;
const flat = []; const vehicles = segments.map((segment, index) => {
for (const coord of segment.coordinates) { const vehicle = addCruiseVehicle(viewer, segment, index, start, speed);
flat.push(coord[0], coord[1], 1.05); const option = document.createElement("option");
} option.value = String(index);
viewer.entities.add({ option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m";
name: "Cruise route", vehicleSelect.appendChild(option);
polyline: { return vehicle;
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
width: 4,
material: Cesium.Color.CYAN.withAlpha(0.85),
clampToGround: false
}
});
const vehicleOrientation = correctedVehicleOrientation(positions, -90.0);
const vehicle = viewer.entities.add({
name: "Cruise vehicle",
availability: new Cesium.TimeIntervalCollection([
new Cesium.TimeInterval({ start, stop })
]),
position: positions,
orientation: vehicleOrientation,
model: {
uri: "${escapeJs(vehicleModelName)}",
scale: 1.0,
minimumPixelSize: 24,
maximumScale: 80
},
path: {
resolution: 1,
leadTime: 4,
trailTime: 10,
width: 2,
material: Cesium.Color.ORANGE.withAlpha(0.9)
}
}); });
const state = { selectedIndex: 0 };
toggleCruise.addEventListener("click", () => { toggleCruise.addEventListener("click", () => {
viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate; viewer.clock.shouldAnimate = !viewer.clock.shouldAnimate;
toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play"; toggleCruise.textContent = viewer.clock.shouldAnimate ? "Pause" : "Play";
@@ -840,7 +808,14 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
viewer.clock.multiplier = value / speed; viewer.clock.multiplier = value / speed;
speedLabel.textContent = value + " m/s"; speedLabel.textContent = value + " m/s";
}); });
const follow = createChaseFollow(viewer, positions); vehicleSelect.addEventListener("change", () => {
state.selectedIndex = Number(vehicleSelect.value || 0);
statusEl.textContent = selectedVehicle().label;
});
function selectedVehicle() {
return vehicles[state.selectedIndex] || vehicles[0];
}
const follow = createChaseFollow(viewer, () => selectedVehicle().positions);
toggleFollow.addEventListener("click", () => { toggleFollow.addEventListener("click", () => {
if (follow.enabled) { if (follow.enabled) {
follow.stop(); follow.stop();
@@ -850,23 +825,118 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
toggleFollow.textContent = "Free"; toggleFollow.textContent = "Free";
} }
}); });
return { segment, vehicle }; statusEl.textContent = selectedVehicle().label;
return { vehicles };
} }
function correctedVehicleOrientation(positions, yawDegrees) { function addCruiseVehicle(viewer, segment, index, start, speed) {
const velocityOrientation = new Cesium.VelocityOrientationProperty(positions); const route = prepareRoute(segment);
const positions = new Cesium.CallbackProperty((time, result) => {
return routePosition(route, start, time, speed, result);
}, false);
const flat = [];
for (const coord of segment.coordinates) {
flat.push(coord[0], coord[1], 1.05);
}
const routeColor = [Cesium.Color.CYAN, Cesium.Color.LIME, Cesium.Color.YELLOW, Cesium.Color.ORANGE, Cesium.Color.DEEPSKYBLUE][index % 5];
viewer.entities.add({
name: "Cruise route " + (index + 1),
polyline: {
positions: Cesium.Cartesian3.fromDegreesArrayHeights(flat),
width: 2,
material: routeColor.withAlpha(0.75),
clampToGround: false
}
});
const vehicle = viewer.entities.add({
name: "Cruise vehicle " + (index + 1),
position: positions,
orientation: routeOrientation(route, start, speed, 0.0),
model: {
uri: "${escapeJs(vehicleModelName)}",
scale: 1.0,
minimumPixelSize: 24,
maximumScale: 80
}
});
return {
entity: vehicle,
positions,
segment,
label: "Vehicle #" + (index + 1) + " | route " + segment.id + " | " + Math.round(segment.lengthMeters) + "m"
};
}
function prepareRoute(segment) {
const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) {
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
}
return {
coordinates: segment.coordinates,
distances,
length: Math.max(1.0, distances[distances.length - 1])
};
}
function routePosition(route, start, time, speed, result) {
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
const distance = (seconds * speed) % route.length;
let index = 1;
while (index < route.distances.length - 1 && route.distances[index] < distance) {
index += 1;
}
const prevDist = route.distances[index - 1];
const nextDist = route.distances[index];
const t = nextDist > prevDist ? (distance - prevDist) / (nextDist - prevDist) : 0;
const a = route.coordinates[index - 1];
const b = route.coordinates[index];
const lon = a[0] + (b[0] - a[0]) * t;
const lat = a[1] + (b[1] - a[1]) * t;
return Cesium.Cartesian3.fromDegrees(lon, lat, 1.15, Cesium.Ellipsoid.WGS84, result);
}
function routeOrientation(route, start, speed, yawDegrees) {
const correction = Cesium.Quaternion.fromAxisAngle( const correction = Cesium.Quaternion.fromAxisAngle(
Cesium.Cartesian3.UNIT_Z, Cesium.Cartesian3.UNIT_Z,
Cesium.Math.toRadians(yawDegrees) Cesium.Math.toRadians(yawDegrees)
); );
const current = new Cesium.Cartesian3();
const ahead = new Cesium.Cartesian3();
const direction = new Cesium.Cartesian3();
const up = new Cesium.Cartesian3();
const east = new Cesium.Cartesian3();
const north = new Cesium.Cartesian3();
return new Cesium.CallbackProperty((time, result) => { return new Cesium.CallbackProperty((time, result) => {
const base = velocityOrientation.getValue(time); routePosition(route, start, time, speed, current);
if (!base) return result; const aheadTime = Cesium.JulianDate.addSeconds(time, 0.8, new Cesium.JulianDate());
routePosition(route, start, aheadTime, speed, ahead);
Cesium.Cartesian3.subtract(ahead, current, direction);
if (Cesium.Cartesian3.magnitudeSquared(direction) < 0.0001) {
return result;
}
Cesium.Cartesian3.normalize(direction, direction);
Cesium.Cartesian3.normalize(current, up);
Cesium.Cartesian3.cross(Cesium.Cartesian3.UNIT_Z, up, east);
if (Cesium.Cartesian3.magnitudeSquared(east) < 0.0001) {
Cesium.Cartesian3.clone(Cesium.Cartesian3.UNIT_X, east);
} else {
Cesium.Cartesian3.normalize(east, east);
}
Cesium.Cartesian3.cross(up, east, north);
Cesium.Cartesian3.normalize(north, north);
const eastComponent = Cesium.Cartesian3.dot(direction, east);
const northComponent = Cesium.Cartesian3.dot(direction, north);
const heading = Math.atan2(eastComponent, northComponent);
const base = Cesium.Transforms.headingPitchRollQuaternion(
current,
new Cesium.HeadingPitchRoll(heading, 0.0, 0.0)
);
return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion()); return Cesium.Quaternion.multiply(base, correction, result || new Cesium.Quaternion());
}, false); }, false);
} }
function createChaseFollow(viewer, positions) { function createChaseFollow(viewer, positionsProvider) {
const scratchPosition = new Cesium.Cartesian3(); const scratchPosition = new Cesium.Cartesian3();
const scratchPrevious = new Cesium.Cartesian3(); const scratchPrevious = new Cesium.Cartesian3();
const scratchDirection = new Cesium.Cartesian3(); const scratchDirection = new Cesium.Cartesian3();
@@ -878,6 +948,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
function update(clock) { function update(clock) {
const time = clock.currentTime; const time = clock.currentTime;
const positions = positionsProvider();
const position = positions.getValue(time, scratchPosition); const position = positions.getValue(time, scratchPosition);
if (!position) return; if (!position) return;
const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate()); const previousTime = Cesium.JulianDate.addSeconds(time, -0.8, new Cesium.JulianDate());