feat(preview): pause vehicles for traffic signals

This commit is contained in:
2026-08-06 08:39:31 +08:00
parent 8b3a1d79d9
commit 9fbc218e10
5 changed files with 85 additions and 24 deletions

View File

@@ -13,8 +13,12 @@ Entity/Primitive 构成灯杆、灯头和发光灯珠。
侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时, 侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时,
不输出锚点。 不输出锚点。
输出保存区域局部 ENU 坐标、朝向和稳定 ID避免浏览器重新解析 GeoJSON 或 OSM。每个 输出保存灯杆坐标、对应停止线坐标、朝向和稳定 ID避免浏览器重新解析 GeoJSON 或 OSM。
路口按相对进口方向分为两组对向相位;首版统一循环绿、黄、全红切换。没有车辆等待逻辑。 每个路口按相对进口方向分为两组对向相位;统一循环绿、黄、全红切换。
预览将每条路线按累计米数投影到信号停止线。只有距离阈值内且行驶方向与信号进口一致的
匹配才形成停车点。车辆的累计里程由 `clock.onTick` 推进;下一停车点为红或黄时将里程夹在
停止线前,绿灯后从同一位置继续。没有停车点的路线保留原速度循环。
## 预览交互 ## 预览交互

View File

@@ -2,9 +2,10 @@
1. 从现有停止线和路口面建立稳定的信号锚点生成器,输出区域局部坐标、朝向、路口与相位组。 1. 从现有停止线和路口面建立稳定的信号锚点生成器,输出区域局部坐标、朝向、路口与相位组。
2. 将锚点 JSON 作为 preview 的可选支持文件写入区域输出与 preview manifest不触及主 GLB。 2. 将锚点 JSON 作为 preview 的可选支持文件写入区域输出与 preview manifest不触及主 GLB。
3. 在 Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位钟更新状态。 3. 在 Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位钟更新状态。
4. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局 4. 将路线投影到匹配停止线,在红/黄相位冻结累计里程、绿灯恢复推进;未匹配路线保持原行为
5. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、朝向与相位 5. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局
6. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、相位与停车。
## 验证 ## 验证

View File

@@ -3,8 +3,8 @@
## Goal ## Goal
在 Cesium 预览中加入可直接观察的路口交通信号灯,使已完成的道路、停止线、斑马线、 在 Cesium 预览中加入可直接观察的路口交通信号灯,使已完成的道路、停止线、斑马线、
转向箭头和车辆巡航有清晰的交通控制参照。首轮以可见的灯具和相位切换为目标,不提前 转向箭头和车辆巡航有清晰的交通控制参照。车辆应在对应停止线前遵守同一套相位,并在
实现车辆等待或交通仿真 绿灯放行后继续巡航
## Confirmed Facts ## Confirmed Facts
@@ -21,7 +21,9 @@
面向来车方向。 面向来车方向。
- 红、黄、绿灯应有明确的点亮状态和周期性相位切换,使静态截图与实时预览都能看出其作用。 - 红、黄、绿灯应有明确的点亮状态和周期性相位切换,使静态截图与实时预览都能看出其作用。
- 信号灯应能独立显示/隐藏,遵循现有 Cesium 控件的克制设计语言。 - 信号灯应能独立显示/隐藏,遵循现有 Cesium 控件的克制设计语言。
- 首轮不改变车辆路线、车速或等待行为;车辆仍按现有路线巡航。 - 车辆接近已匹配的停止线时,红灯和黄灯必须停车,绿灯继续通行;相位切换后应自然恢复
移动,不得瞬移到路口另一侧。
- 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。
- 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠 - 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠
几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。 几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。
@@ -31,7 +33,9 @@
斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。 斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。
- [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。 - [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。
- [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。 - [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。
- [ ] 不修改 QGIS 工程、道路/标线生成、现有路线 JSON 契约或车辆巡航行为。 - [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的
路线保持原有循环巡航,不因信号锚点缺失而卡住。
- [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。
## Notes ## Notes

View File

@@ -40,8 +40,9 @@
const viewer = createViewer(); const viewer = createViewer();
setLoadingMessage("Loading model", config.glbName || ""); setLoadingMessage("Loading model", config.glbName || "");
const assets = await loadSceneAssets(viewer, metadata, placement); const assets = await loadSceneAssets(viewer, metadata, placement);
const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelNames, config.vehicleModelName); const trafficStart = Cesium.JulianDate.now();
const trafficSignals = addTrafficSignals(viewer, signalData); const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart);
const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName);
const cameras = createCameraPresets(viewer, metadata, placement, cruise); const cameras = createCameraPresets(viewer, metadata, placement, cruise);
buildAssetToggles(assets); buildAssetToggles(assets);
@@ -434,12 +435,12 @@
return stopFollow; return stopFollow;
} }
function addVehicleCruises(viewer, routeData, vehicleModelNames, fallbackVehicleModelName) { function addVehicleCruises(viewer, routeData, signalData, trafficStart, vehicleModelNames, fallbackVehicleModelName) {
const segments = ((routeData && (routeData.routes || routeData.segments)) || []) const segments = ((routeData && (routeData.routes || routeData.segments)) || [])
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2) .filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
.slice(0, 5); .slice(0, 5);
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8); const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
const start = Cesium.JulianDate.now(); const start = trafficStart;
viewer.clock.startTime = start.clone(); viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone(); viewer.clock.currentTime = start.clone();
@@ -448,7 +449,7 @@
viewer.clock.shouldAnimate = segments.length > 0; viewer.clock.shouldAnimate = segments.length > 0;
const vehicles = segments.map((segment, index) => { const vehicles = segments.map((segment, index) => {
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName)); selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
const option = document.createElement("option"); const option = document.createElement("option");
option.value = String(index); option.value = String(index);
@@ -463,10 +464,9 @@
}; };
} }
function addTrafficSignals(viewer, signalData) { function addTrafficSignals(viewer, signalData, start) {
const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude)); const anchors = (signalData?.signals || []).filter((signal) => Number.isFinite(signal.longitude) && Number.isFinite(signal.latitude));
const entities = []; const entities = [];
const start = Cesium.JulianDate.now();
for (const signal of anchors) { for (const signal of anchors) {
const mastReach = Number(signal.mastReachMeters) || 4.5; const mastReach = Number(signal.mastReachMeters) || 4.5;
const countdownOffset = -1.15; const countdownOffset = -1.15;
@@ -639,11 +639,9 @@
return usable[Math.floor(Math.random() * usable.length)] || ""; return usable[Math.floor(Math.random() * usable.length)] || "";
} }
function addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName) { function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment); const route = prepareRoute(segment, signalData);
const positions = new Cesium.CallbackProperty((time, result) => { const positions = createTrafficAwarePositions(viewer, route, start, speed);
return routePosition(route, start, time, speed, result);
}, false);
const flat = []; const flat = [];
for (const coord of segment.coordinates) { for (const coord of segment.coordinates) {
flat.push(coord[0], coord[1], 1.05); flat.push(coord[0], coord[1], 1.05);
@@ -668,7 +666,7 @@
const vehicle = viewer.entities.add({ const vehicle = viewer.entities.add({
name: "Cruise vehicle " + (index + 1), name: "Cruise vehicle " + (index + 1),
position: positions, position: positions,
orientation: routeOrientation(route, start, speed, 0.0), orientation: routeOrientationFromPositions(positions),
model: { model: {
uri: vehicleModelName, uri: vehicleModelName,
// Keep the library's world scale visible. A minimum screen size made // Keep the library's world scale visible. A minimum screen size made
@@ -705,7 +703,7 @@
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0]; return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
} }
function prepareRoute(segment) { function prepareRoute(segment, signalData) {
const distances = [0.0]; const distances = [0.0];
for (let i = 1; i < segment.coordinates.length; i += 1) { for (let i = 1; i < segment.coordinates.length; i += 1) {
distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i])); distances.push(distances[i - 1] + distanceMeters(segment.coordinates[i - 1], segment.coordinates[i]));
@@ -713,13 +711,61 @@
return { return {
coordinates: segment.coordinates, coordinates: segment.coordinates,
distances, distances,
length: Math.max(1.0, distances[distances.length - 1]) length: Math.max(1.0, distances[distances.length - 1]),
stops: routeStops(segment.coordinates, distances, signalData),
}; };
} }
function routeStops(coordinates, distances, signalData) {
const found = new Map();
for (const signal of signalData?.signals || []) {
if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) continue;
let best = { index: -1, distance: Infinity };
for (let index = 0; index < coordinates.length; index += 1) {
const distance = distanceMeters(coordinates[index], [signal.stopLongitude, signal.stopLatitude]);
if (distance < best.distance) best = { index, distance };
}
if (best.index >= 0 && best.distance <= 7) {
const routeDistance = distances[best.index];
const existing = found.get(Math.round(routeDistance));
if (!existing || best.distance < existing.matchDistance) found.set(Math.round(routeDistance), { distance: routeDistance, matchDistance: best.distance, signal });
}
}
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
function createTrafficAwarePositions(viewer, route, start, speed) {
const state = { distance: 0, lastTime: start.clone() };
const scratchTime = new Cesium.JulianDate();
viewer.clock.onTick.addEventListener((clock) => {
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, state.lastTime);
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
if (elapsed <= 0) return;
const next = nextRouteStop(route, state.distance);
const advance = elapsed * speed;
if (next && signalPhase(next.signal.phaseGroup, clock.currentTime, start).active !== "green") {
const untilStop = (next.distance - state.distance + route.length) % route.length;
if (untilStop <= advance + 1.7) {
state.distance = (next.distance - 1.7 + route.length) % route.length;
return;
}
}
state.distance = (state.distance + advance) % route.length;
});
return new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false);
}
function nextRouteStop(route, distance) {
return route.stops.find((stop) => stop.distance > distance + 0.05) || route.stops[0] || null;
}
function routePosition(route, start, time, speed, result) { function routePosition(route, start, time, speed, result) {
const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start)); const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start));
const distance = (seconds * speed) % route.length; const distance = (seconds * speed) % route.length;
return routePositionAtDistance(route, distance, result);
}
function routePositionAtDistance(route, distance, result) {
let index = 1; let index = 1;
while (index < route.distances.length - 1 && route.distances[index] < distance) { while (index < route.distances.length - 1 && route.distances[index] < distance) {
index += 1; index += 1;
@@ -780,6 +826,10 @@
}, false); }, false);
} }
function routeOrientationFromPositions(positions) {
return new Cesium.VelocityOrientationProperty(positions);
}
function createChaseFollow(viewer, positionsProvider) { function createChaseFollow(viewer, positionsProvider) {
const scratchPosition = new Cesium.Cartesian3(); const scratchPosition = new Cesium.Cartesian3();
const scratchPrevious = new Cesium.Cartesian3(); const scratchPrevious = new Cesium.Cartesian3();

View File

@@ -33,6 +33,8 @@ function buildTrafficSignals(stopLines, intersections) {
phaseGroup: signals.length % 2, phaseGroup: signals.length % 2,
longitude: point[0], longitude: point[0],
latitude: point[1], latitude: point[1],
stopLongitude: center[0],
stopLatitude: center[1],
headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI, headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI,
mastReachMeters: MAST_REACH_METERS, mastReachMeters: MAST_REACH_METERS,
}); });