From 9fbc218e10b892f433f162782b0fadf698bb87db Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 6 Aug 2026 08:39:31 +0800 Subject: [PATCH] feat(preview): pause vehicles for traffic signals --- .../08-05-cesium-traffic-signals/design.md | 8 +- .../08-05-cesium-traffic-signals/implement.md | 7 +- .../tasks/08-05-cesium-traffic-signals/prd.md | 12 ++- scripts/lib/cesium-preview.js | 80 +++++++++++++++---- scripts/lib/traffic-signals.js | 2 + 5 files changed, 85 insertions(+), 24 deletions(-) diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/design.md b/.trellis/tasks/08-05-cesium-traffic-signals/design.md index e7c57e8..890ca05 100644 --- a/.trellis/tasks/08-05-cesium-traffic-signals/design.md +++ b/.trellis/tasks/08-05-cesium-traffic-signals/design.md @@ -13,8 +13,12 @@ Entity/Primitive 构成灯杆、灯头和发光灯珠。 侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时, 不输出锚点。 -输出只保存区域局部 ENU 坐标、朝向和稳定 ID,避免浏览器重新解析 GeoJSON 或 OSM。每个 -路口按相对进口方向分为两组对向相位;首版统一循环绿、黄、全红切换。没有车辆等待逻辑。 +输出保存灯杆坐标、对应停止线坐标、朝向和稳定 ID,避免浏览器重新解析 GeoJSON 或 OSM。 +每个路口按相对进口方向分为两组对向相位;统一循环绿、黄、全红切换。 + +预览将每条路线按累计米数投影到信号停止线。只有距离阈值内且行驶方向与信号进口一致的 +匹配才形成停车点。车辆的累计里程由 `clock.onTick` 推进;下一停车点为红或黄时将里程夹在 +停止线前,绿灯后从同一位置继续。没有停车点的路线保留原速度循环。 ## 预览交互 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/implement.md b/.trellis/tasks/08-05-cesium-traffic-signals/implement.md index 0b12af5..676d0a0 100644 --- a/.trellis/tasks/08-05-cesium-traffic-signals/implement.md +++ b/.trellis/tasks/08-05-cesium-traffic-signals/implement.md @@ -2,9 +2,10 @@ 1. 从现有停止线和路口面建立稳定的信号锚点生成器,输出区域局部坐标、朝向、路口与相位组。 2. 将锚点 JSON 作为 preview 的可选支持文件写入区域输出与 preview manifest,不触及主 GLB。 -3. 在 Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位时钟更新状态。 -4. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局。 -5. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、朝向与相位。 +3. 在 Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位钟更新状态。 +4. 将路线投影到匹配停止线,在红/黄相位冻结累计里程、绿灯恢复推进;未匹配路线保持原行为。 +5. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局。 +6. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、相位与停车。 ## 验证 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/prd.md b/.trellis/tasks/08-05-cesium-traffic-signals/prd.md index 5d78551..ec6bda8 100644 --- a/.trellis/tasks/08-05-cesium-traffic-signals/prd.md +++ b/.trellis/tasks/08-05-cesium-traffic-signals/prd.md @@ -3,8 +3,8 @@ ## Goal 在 Cesium 预览中加入可直接观察的路口交通信号灯,使已完成的道路、停止线、斑马线、 -转向箭头和车辆巡航有清晰的交通控制参照。首轮以可见的灯具和相位切换为目标,不提前 -实现车辆等待或交通仿真。 +转向箭头和车辆巡航有清晰的交通控制参照。车辆应在对应停止线前遵守同一套相位,并在 +绿灯放行后继续巡航。 ## Confirmed Facts @@ -21,7 +21,9 @@ 面向来车方向。 - 红、黄、绿灯应有明确的点亮状态和周期性相位切换,使静态截图与实时预览都能看出其作用。 - 信号灯应能独立显示/隐藏,遵循现有 Cesium 控件的克制设计语言。 -- 首轮不改变车辆路线、车速或等待行为;车辆仍按现有路线巡航。 +- 车辆接近已匹配的停止线时,红灯和黄灯必须停车,绿灯继续通行;相位切换后应自然恢复 + 移动,不得瞬移到路口另一侧。 +- 等待逻辑只模拟单车对信号的响应,不做车辆间跟车距离、排队或碰撞避让。 - 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠 几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。 @@ -31,7 +33,9 @@ 斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。 - [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。 - [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。 -- [ ] 不修改 QGIS 工程、道路/标线生成、现有路线 JSON 契约或车辆巡航行为。 +- [ ] 匹配到信号停止线的巡航车辆会在红/黄灯时停在线前,绿灯后连续通过;没有可靠匹配的 + 路线保持原有循环巡航,不因信号锚点缺失而卡住。 +- [ ] 不修改 QGIS 工程、道路/标线生成或既有路线 JSON 的基本契约。 ## Notes diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 38b4b16..7c596e7 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -40,8 +40,9 @@ const viewer = createViewer(); setLoadingMessage("Loading model", config.glbName || ""); const assets = await loadSceneAssets(viewer, metadata, placement); - const cruise = addVehicleCruises(viewer, routeData, config.vehicleModelNames, config.vehicleModelName); - const trafficSignals = addTrafficSignals(viewer, signalData); + const trafficStart = Cesium.JulianDate.now(); + const trafficSignals = addTrafficSignals(viewer, signalData, trafficStart); + const cruise = addVehicleCruises(viewer, routeData, signalData, trafficStart, config.vehicleModelNames, config.vehicleModelName); const cameras = createCameraPresets(viewer, metadata, placement, cruise); buildAssetToggles(assets); @@ -434,12 +435,12 @@ return stopFollow; } - function addVehicleCruises(viewer, routeData, vehicleModelNames, fallbackVehicleModelName) { + function addVehicleCruises(viewer, routeData, signalData, trafficStart, vehicleModelNames, fallbackVehicleModelName) { const segments = ((routeData && (routeData.routes || routeData.segments)) || []) .filter((segment) => segment.coordinates && segment.coordinates.length >= 2) .slice(0, 5); const speed = Number((routeData && routeData.speedMetersPerSecond) || 8); - const start = Cesium.JulianDate.now(); + const start = trafficStart; viewer.clock.startTime = start.clone(); viewer.clock.currentTime = start.clone(); @@ -448,7 +449,7 @@ viewer.clock.shouldAnimate = segments.length > 0; 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)); const option = document.createElement("option"); 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 entities = []; - const start = Cesium.JulianDate.now(); for (const signal of anchors) { const mastReach = Number(signal.mastReachMeters) || 4.5; const countdownOffset = -1.15; @@ -639,11 +639,9 @@ return usable[Math.floor(Math.random() * usable.length)] || ""; } - function addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName) { - const route = prepareRoute(segment); - const positions = new Cesium.CallbackProperty((time, result) => { - return routePosition(route, start, time, speed, result); - }, false); + function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) { + const route = prepareRoute(segment, signalData); + const positions = createTrafficAwarePositions(viewer, route, start, speed); const flat = []; for (const coord of segment.coordinates) { flat.push(coord[0], coord[1], 1.05); @@ -668,7 +666,7 @@ const vehicle = viewer.entities.add({ name: "Cruise vehicle " + (index + 1), position: positions, - orientation: routeOrientation(route, start, speed, 0.0), + orientation: routeOrientationFromPositions(positions), model: { uri: vehicleModelName, // 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]; } - function prepareRoute(segment) { + function prepareRoute(segment, signalData) { 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])); @@ -713,13 +711,61 @@ return { coordinates: segment.coordinates, 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) { const seconds = Math.max(0, Cesium.JulianDate.secondsDifference(time, start)); const distance = (seconds * speed) % route.length; + return routePositionAtDistance(route, distance, result); + } + + function routePositionAtDistance(route, distance, result) { let index = 1; while (index < route.distances.length - 1 && route.distances[index] < distance) { index += 1; @@ -780,6 +826,10 @@ }, false); } + function routeOrientationFromPositions(positions) { + return new Cesium.VelocityOrientationProperty(positions); + } + function createChaseFollow(viewer, positionsProvider) { const scratchPosition = new Cesium.Cartesian3(); const scratchPrevious = new Cesium.Cartesian3(); diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index aa254b1..79034c3 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -33,6 +33,8 @@ function buildTrafficSignals(stopLines, intersections) { phaseGroup: signals.length % 2, longitude: point[0], latitude: point[1], + stopLongitude: center[0], + stopLatitude: center[1], headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI, mastReachMeters: MAST_REACH_METERS, });