From 8b3a1d79d9da0dcebc2fcc2d2d1c5b0f2e31825f Mon Sep 17 00:00:00 2001 From: que01 Date: Wed, 5 Aug 2026 18:09:37 +0800 Subject: [PATCH] feat(preview): add traffic signal visualization --- .../08-05-cesium-traffic-signals/check.jsonl | 1 + .../08-05-cesium-traffic-signals/design.md | 28 +++ .../implement.jsonl | 1 + .../08-05-cesium-traffic-signals/implement.md | 20 ++ .../tasks/08-05-cesium-traffic-signals/prd.md | 40 ++++ .../08-05-cesium-traffic-signals/task.json | 26 +++ scripts/build-area.js | 14 +- scripts/lib/area-config.js | 1 + scripts/lib/area-preview.js | 5 +- scripts/lib/cesium-preview.js | 191 +++++++++++++++++- scripts/lib/traffic-signals.js | 91 +++++++++ scripts/test-preview-assets.js | 27 +++ 12 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 .trellis/tasks/08-05-cesium-traffic-signals/check.jsonl create mode 100644 .trellis/tasks/08-05-cesium-traffic-signals/design.md create mode 100644 .trellis/tasks/08-05-cesium-traffic-signals/implement.jsonl create mode 100644 .trellis/tasks/08-05-cesium-traffic-signals/implement.md create mode 100644 .trellis/tasks/08-05-cesium-traffic-signals/prd.md create mode 100644 .trellis/tasks/08-05-cesium-traffic-signals/task.json create mode 100644 scripts/lib/traffic-signals.js diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/check.jsonl b/.trellis/tasks/08-05-cesium-traffic-signals/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-traffic-signals/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/design.md b/.trellis/tasks/08-05-cesium-traffic-signals/design.md new file mode 100644 index 0000000..e7c57e8 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-traffic-signals/design.md @@ -0,0 +1,28 @@ +# 设计:Cesium 路口信号灯可视化 + +## 边界 + +信号灯属于 Cesium 验证预览层,不进入 QGIS、GeoPackage、场景九图层、Blender 或主 GLB。 +区域构建只额外写入一个轻量的信号锚点 JSON;预览运行时读该 JSON 后以 Cesium 原生 +Entity/Primitive 构成灯杆、灯头和发光灯珠。 + +## 锚点与几何 + +中间阶段已有 `vehicle_stop_lines` 和 `intersection_surface`。信号锚点生成器将停止线的 +中点作为进口横向基准,依据相交路口面和停止线两端的方向判定来车朝向;灯杆置于停止线的 +侧后方、道路外缘一侧,且朝向来车。没有可唯一关联的路口面、停止线过短或无法确定外侧时, +不输出锚点。 + +输出只保存区域局部 ENU 坐标、朝向和稳定 ID,避免浏览器重新解析 GeoJSON 或 OSM。每个 +路口按相对进口方向分为两组对向相位;首版统一循环绿、黄、全红切换。没有车辆等待逻辑。 + +## 预览交互 + +预览加载锚点 JSON 失败时记录 warning,场景、路线与车辆仍可用。加载成功时信号灯默认显示, +并在现有 View 控件中提供独立 Signals 复选框。灯珠以 emissive 材质区分点亮和熄灭状态, +不依赖环境光;灯杆用低多边形几何,避免增加外部模型资产。 + +## 风险与回退 + +信号灯是示意设施,不能视为 OSM 语义。复杂交叉口或人工修补后的不完整标线宁可跳过,也不 +摆放到行车道中央。删除新锚点输出及预览加载逻辑即可完整回退,不影响既有主 GLB 或路线 JSON。 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/implement.jsonl b/.trellis/tasks/08-05-cesium-traffic-signals/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-traffic-signals/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/implement.md b/.trellis/tasks/08-05-cesium-traffic-signals/implement.md new file mode 100644 index 0000000..0b12af5 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-traffic-signals/implement.md @@ -0,0 +1,20 @@ +# 实施计划:Cesium 路口信号灯可视化 + +1. 从现有停止线和路口面建立稳定的信号锚点生成器,输出区域局部坐标、朝向、路口与相位组。 +2. 将锚点 JSON 作为 preview 的可选支持文件写入区域输出与 preview manifest,不触及主 GLB。 +3. 在 Cesium 运行时加载可选锚点,构造立杆、灯头、红黄绿灯珠,并以统一相位时钟更新状态。 +4. 添加 Signals 显示开关和诊断摘要,保持加载降级语义与既有 Controls 的布局。 +5. 为锚点推导和预览配置/运行时编写针对性测试;构建目标区域并人工核对位置、朝向与相位。 + +## 验证 + +```bash +npm run test:preview-assets +node --check scripts/lib/cesium-preview.js +npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages preview +git diff --check +``` + +## 回退 + +删除信号锚点的 preview 输出与浏览器加载代码;QGIS、GLB、车辆路线和原有预览功能不受影响。 diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/prd.md b/.trellis/tasks/08-05-cesium-traffic-signals/prd.md new file mode 100644 index 0000000..5d78551 --- /dev/null +++ b/.trellis/tasks/08-05-cesium-traffic-signals/prd.md @@ -0,0 +1,40 @@ +# Cesium 路口信号灯可视化 + +## Goal + +在 Cesium 预览中加入可直接观察的路口交通信号灯,使已完成的道路、停止线、斑马线、 +转向箭头和车辆巡航有清晰的交通控制参照。首轮以可见的灯具和相位切换为目标,不提前 +实现车辆等待或交通仿真。 + +## Confirmed Facts + +- `scripts/lib/cesium-preview.js` 目前只加载场景资产、巡航路线与车辆;没有信号灯图层、 + 模型或相位状态。 +- 路线 JSON 包含连续的左、右、直转向曲线,但没有路口 ID 或信号相位字段。 +- 现有停止线、斑马线和转向箭头已由区域构建确认,且用户要求暂不触及 QGIS 的人工修补 + 边界、道路生成与既有连续路线逻辑。 +- Cesium 预览是验证层;改动它不应改变主 GLB、Blender 导出或 OSM/QGIS 产物。 + +## Requirements + +- 在道路进口侧、停止线附近呈现简洁而可辨识的交通信号灯,并在不遮挡车道标线的前提下 + 面向来车方向。 +- 红、黄、绿灯应有明确的点亮状态和周期性相位切换,使静态截图与实时预览都能看出其作用。 +- 信号灯应能独立显示/隐藏,遵循现有 Cesium 控件的克制设计语言。 +- 首轮不改变车辆路线、车速或等待行为;车辆仍按现有路线巡航。 +- 首版以主要路口的程序化示意灯覆盖为准:从已生成的停止线和路口面推导进口,缺少可靠 + 几何锚点时跳过。它不宣称复刻 OSM 中逐节点标注的真实信号设施。 + +## Acceptance Criteria + +- [ ] 重建目标区域的 preview 后,Cesium 中可见至少一组位于路口进口侧的信号灯,位置与停止线/ + 斑马线关系清楚,且不会漂浮在道路中央或遮挡车道箭头。 +- [ ] 灯组以可见状态呈现红、黄、绿的相位切换;未点亮灯珠明显较暗。 +- [ ] 页面提供独立的 Signals 显示开关,关闭后不影响场景、路线与车辆。 +- [ ] 不修改 QGIS 工程、道路/标线生成、现有路线 JSON 契约或车辆巡航行为。 + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. diff --git a/.trellis/tasks/08-05-cesium-traffic-signals/task.json b/.trellis/tasks/08-05-cesium-traffic-signals/task.json new file mode 100644 index 0000000..998b2da --- /dev/null +++ b/.trellis/tasks/08-05-cesium-traffic-signals/task.json @@ -0,0 +1,26 @@ +{ + "id": "cesium-traffic-signals", + "name": "cesium-traffic-signals", + "title": "Cesium 路口信号灯可视化", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-05", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/scripts/build-area.js b/scripts/build-area.js index 5dbfab0..8ee4d0b 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -14,6 +14,7 @@ const { const { digest: glbDigest } = require("./glb-digest"); const { buildVehicleRoute: buildPreviewVehicleRoute } = require("./lib/vehicle-route"); const { writePreviewVehicleLibrary } = require("./lib/vehicle-library"); +const { readTrafficSignals } = require("./lib/traffic-signals"); const { cesiumPreviewHtml, previewSummary, @@ -470,13 +471,14 @@ function writeCesiumPreview(area) { const startedAt = new Date(started).toISOString(); fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); writeVehicleRoute(area); + writeTrafficSignals(area); const vehicleModelNames = writeVehicleModel(area); writeCesiumPreviewSupportFiles(path.dirname(htmlPath)); const glbName = path.basename(area.outputs.glb); const metadataName = path.basename(area.outputs.metadata); const routeName = path.basename(area.outputs.vehicleRoute); const vehicleModelName = path.basename(area.outputs.vehicleModel); - fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames)); + fs.writeFileSync(htmlPath, cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, area.id, vehicleModelNames, path.basename(area.outputs.trafficSignals))); console.log(`Cesium preview: ${htmlPath}`); const finished = Date.now(); writeStageManifest(area, { @@ -498,12 +500,22 @@ function writeCesiumPreview(area) { cesiumPreview: fileRecord(area.outputs.cesiumPreview), vehicleRoute: fileRecord(area.outputs.vehicleRoute), vehicleModel: fileRecord(area.outputs.vehicleModel), + trafficSignals: fileRecord(area.outputs.trafficSignals), }, summary: previewSummary(area), warnings: [], }); } +function writeTrafficSignals(area) { + const signals = readTrafficSignals( + path.join(area.outputs.geojsonDir, "vehicle_stop_lines.geojson"), + path.join(area.outputs.geojsonDir, "intersection_surface.geojson"), + ); + fs.writeFileSync(area.outputs.trafficSignals, `${JSON.stringify(signals, null, 2)}\n`); + console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`); +} + function writeVehicleRoute(area) { const route = buildPreviewVehicleRoute(area.input); fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true }); diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 9281602..a717a60 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -52,6 +52,7 @@ function normalizeAreaConfig(raw, options = {}) { ), vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)), vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)), + trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(areaDir, `${fileStem}-traffic-signals.json`)), pipelineDir, stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")), }; diff --git a/scripts/lib/area-preview.js b/scripts/lib/area-preview.js index d984b5c..83bb219 100644 --- a/scripts/lib/area-preview.js +++ b/scripts/lib/area-preview.js @@ -13,7 +13,7 @@ function writeCesiumPreviewSupportFiles(outDir) { } } -function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = []) { +function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, areaId, vehicleModelNames = [], trafficSignalsName = null) { const previewConfig = { areaId, glbName, @@ -21,6 +21,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a routeName, vehicleModelName, vehicleModelNames, + trafficSignalsName, }; return ` @@ -53,6 +54,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a + @@ -106,6 +108,7 @@ function previewSummary(area) { metadataName: path.basename(area.outputs.metadata), routeName: path.basename(area.outputs.vehicleRoute), vehicleModelName: path.basename(area.outputs.vehicleModel), + trafficSignalsName: path.basename(area.outputs.trafficSignals), routeSegments: Array.isArray(route.segments) ? route.segments.length : null, }; } diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 8ed62f1..38b4b16 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -10,6 +10,8 @@ const toggleScene = document.getElementById("toggleScene"); const toggleRoutes = document.getElementById("toggleRoutes"); const toggleVehicles = document.getElementById("toggleVehicles"); + const toggleSignals = document.getElementById("toggleSignals"); + const signalsControl = document.getElementById("signalsControl"); const toggleFps = document.getElementById("toggleFps"); const toggleDiagnostics = document.getElementById("toggleDiagnostics"); const assetToggles = document.getElementById("assetToggles"); @@ -33,17 +35,19 @@ setLoadingMessage("Loading scene", config.areaId || ""); const metadata = await fetchJson(config.metadataName); const routeData = await fetchOptionalJson(config.routeName); + const signalData = await fetchOptionalJson(config.trafficSignalsName); const placement = scenePlacement(metadata); 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 cameras = createCameraPresets(viewer, metadata, placement, cruise); buildAssetToggles(assets); buildSemanticToggles(viewer, assets, placement); - bindRuntimeControls(viewer, assets, cruise, cameras, placement); - startDiagnostics(viewer, metadata, assets, cruise, placement); + bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals); + startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals); cameras.overview(); baseStatus = summaryText(metadata, assets, cruise); setStatus(baseStatus); @@ -52,7 +56,7 @@ document.body.classList.add("scene-ready"); // Handle for the browser console and for headless checks: everything else // in here is closed over by the IIFE and unreachable from outside. - window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras }; + window.osmPreview = { viewer, metadata, placement, assets, cruise, trafficSignals, cameras }; } async function fetchJson(url) { @@ -272,7 +276,7 @@ } } - function bindRuntimeControls(viewer, assets, cruise, cameras, placement) { + function bindRuntimeControls(viewer, assets, cruise, cameras, placement, trafficSignals) { const hasVehicles = cruise.vehicles.length > 0; const hasSemanticAssets = semanticAssets(assets).length > 0; const sceneLabel = toggleScene.closest("label"); @@ -292,6 +296,14 @@ toggleVehicles.addEventListener("change", () => { for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked; }); + if (!trafficSignals.entities.length) { + signalsControl.classList.add("hidden"); + } else { + toggleSignals.addEventListener("change", () => { + trafficSignals.show = toggleSignals.checked; + setStatus(toggleSignals.checked ? "Signals visible" : "Signals hidden"); + }); + } toggleFps.addEventListener("change", () => { viewer.scene.debugShowFramesPerSecond = toggleFps.checked; }); @@ -451,6 +463,174 @@ }; } + function addTrafficSignals(viewer, signalData) { + 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; + const polePosition = signalPosition(signal, 0, 0, 3.35); + const headPosition = signalPosition(signal, 0, -mastReach, 6.25); + const frame = signalHeadFrame(signal, headPosition); + const pole = viewer.entities.add({ + position: polePosition, + cylinder: { length: 6.7, topRadius: 0.10, bottomRadius: 0.14, material: Cesium.Color.fromCssColorString("#273139") }, + }); + // The mast arm begins at the curbside pole and reaches above the approach + // lanes. A separate mast at the opposite approach controls oncoming cars. + const arm = viewer.entities.add({ + polyline: { + positions: [signalPosition(signal, 0, 0, 6.25), headPosition], + width: 9, + material: Cesium.Color.fromCssColorString("#273139"), + arcType: Cesium.ArcType.NONE, + }, + }); + const head = viewer.entities.add({ + position: headPosition, + orientation: frame.orientation, + box: { dimensions: new Cesium.Cartesian3(0.68, 0.30, 1.62), material: Cesium.Color.fromCssColorString("#182024") }, + }); + entities.push(pole, arm, head); + for (const [index, state] of ["red", "yellow", "green"].entries()) { + const bulb = viewer.entities.add({ + // The lens sits on the explicit approach-facing normal of the head, + // not at an angle inferred from the box's local axes. + position: signalLensPosition(headPosition, frame, 0.18, 0.49 - index * 0.50), + ellipsoid: { + radii: new Cesium.Cartesian3(0.22, 0.22, 0.22), + material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalColor(signal.phaseGroup, state, time, start), false)), + }, + }); + entities.push(bulb); + } + // The countdown board mounts on the mast between the head and pole, + // rather than protruding beyond the signal on the roadway side. + const counterPosition = signalPanelPosition(headPosition, frame, countdownOffset, 0.05, 0); + const counter = viewer.entities.add({ + position: counterPosition, + orientation: frame.orientation, + box: { + dimensions: new Cesium.Cartesian3(0.82, 0.14, 0.56), + material: Cesium.Color.fromCssColorString("#251f1c"), + distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220), + }, + }); + const countdown = createSevenSegmentCountdown( + viewer, + signal, + start, + headPosition, + frame, + countdownOffset, + ); + entities.push(counter, ...countdown); + } + return { + entities, + count: anchors.length, + set show(value) { for (const entity of entities) entity.show = value; }, + }; + } + + function signalPosition(signal, longitudinalMeters, lateralMeters, height) { + const heading = Cesium.Math.toRadians(signal.headingDegrees); + const latitude = signal.latitude + (longitudinalMeters * Math.cos(heading) - lateralMeters * Math.sin(heading)) / 110540; + const longitude = signal.longitude + (longitudinalMeters * Math.sin(heading) + lateralMeters * Math.cos(heading)) / + (111320 * Math.cos(Cesium.Math.toRadians(signal.latitude))); + return Cesium.Cartesian3.fromDegrees(longitude, latitude, height); + } + + function signalHeadFrame(signal, position) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position); + // headingDegrees is the approach's travel direction into the junction. + // The signal's front must point back toward that approaching traffic. + const heading = Cesium.Math.toRadians(signal.headingDegrees); + const localFace = new Cesium.Cartesian3(-Math.sin(heading), -Math.cos(heading), 0); + const face = Cesium.Matrix4.multiplyByPointAsVector(enu, localFace, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(face, face); + const up = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3()); + const across = Cesium.Cartesian3.cross(face, up, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(across, across); + const rotation = new Cesium.Matrix3( + across.x, face.x, up.x, + across.y, face.y, up.y, + across.z, face.z, up.z, + ); + return { across, face, up, orientation: Cesium.Quaternion.fromRotationMatrix(rotation, new Cesium.Quaternion()) }; + } + + function signalLensPosition(headPosition, frame, faceOffset, verticalOffset) { + const point = Cesium.Cartesian3.multiplyByScalar(frame.face, faceOffset, new Cesium.Cartesian3()); + Cesium.Cartesian3.add(headPosition, point, point); + const vertical = Cesium.Cartesian3.multiplyByScalar(frame.up, verticalOffset, new Cesium.Cartesian3()); + return Cesium.Cartesian3.add(point, vertical, point); + } + + function signalPanelPosition(headPosition, frame, acrossOffset, faceOffset, verticalOffset) { + const point = signalLensPosition(headPosition, frame, faceOffset, verticalOffset); + const across = Cesium.Cartesian3.multiplyByScalar(frame.across, acrossOffset, new Cesium.Cartesian3()); + return Cesium.Cartesian3.add(point, across, point); + } + + function createSevenSegmentCountdown(viewer, signal, start, headPosition, frame, boardAcross) { + const digitMap = { "0": "abcedf", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc", "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abfgcd" }; + const shape = { + a: [0, 0.18, 0.20, 0.025, 0.035], b: [0.10, 0.085, 0.035, 0.025, 0.15], + c: [0.10, -0.085, 0.035, 0.025, 0.15], d: [0, -0.18, 0.20, 0.025, 0.035], + e: [-0.10, -0.085, 0.035, 0.025, 0.15], f: [-0.10, 0.085, 0.035, 0.025, 0.15], + g: [0, 0, 0.20, 0.025, 0.035], + }; + const result = []; + for (const [digitIndex, digitAcross] of [-0.17, 0.17].entries()) { + for (const [name, [x, z, width, depth, height]] of Object.entries(shape)) { + result.push(viewer.entities.add({ + // The visible panel x-axis is the inverse of the signal frame's + // across axis. Mirror the LED layout once here to keep digits normal. + position: signalPanelPosition(headPosition, frame, boardAcross - digitAcross - x, 0.18, z), + orientation: frame.orientation, + box: { + dimensions: new Cesium.Cartesian3(width, depth, height), + material: new Cesium.ColorMaterialProperty(new Cesium.CallbackProperty((time) => signalActiveColor(signal.phaseGroup, time, start), false)), + show: new Cesium.CallbackProperty((time) => { + const value = String(signalPhase(signal.phaseGroup, time, start).remaining).padStart(2, "0")[digitIndex]; + return (digitMap[value] || "").includes(name); + }, false), + distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 220), + }, + })); + } + } + return result; + } + + function signalColor(group, state, time, start) { + const active = signalPhase(group, time, start).active; + const color = signalBaseColor(state); + return state === active ? color : Cesium.Color.multiplyByScalar(color, 0.35, new Cesium.Color()); + } + + function signalActiveColor(group, time, start) { + return signalBaseColor(signalPhase(group, time, start).active); + } + + function signalBaseColor(state) { + return Cesium.Color.fromCssColorString({ red: "#ee3f39", yellow: "#f7bf37", green: "#43cf71" }[state]); + } + + function signalPhase(group, time, start) { + const second = ((Cesium.JulianDate.secondsDifference(time, start) % 20) + 20) % 20; + if (group === 0) { + if (second < 8) return { active: "green", remaining: Math.ceil(8 - second) }; + if (second < 10) return { active: "yellow", remaining: Math.ceil(10 - second) }; + return { active: "red", remaining: Math.ceil(20 - second) }; + } + if (second < 10) return { active: "red", remaining: Math.ceil(10 - second) }; + if (second < 18) return { active: "green", remaining: Math.ceil(18 - second) }; + return { active: "yellow", remaining: Math.ceil(20 - second) }; + } + function selectedVehicleModelName(modelNames, fallbackModelName) { const choices = Array.isArray(modelNames) && modelNames.length ? modelNames @@ -731,7 +911,7 @@ // Camera-dependent readouts have to track the camera, so refresh off the // render loop rather than a fixed timer, throttled to stay off the hot path. - function startDiagnostics(viewer, metadata, assets, cruise, placement) { + function startDiagnostics(viewer, metadata, assets, cruise, placement, trafficSignals) { const center = placement.position; const stats = metadata.scene_stats || {}; const failed = assets.filter((asset) => asset.error); @@ -747,6 +927,7 @@ "Camera range: " + Math.round(distance) + " m", "Assets: " + liveAssets(assets).length + " model(s)", "Vehicles: " + cruise.vehicles.length, + "Signals: " + trafficSignals.count, "Buildings: " + Number(stats.buildings || 0), "Trees: " + Number(stats.trees || 0), "Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback") diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js new file mode 100644 index 0000000..aa254b1 --- /dev/null +++ b/scripts/lib/traffic-signals.js @@ -0,0 +1,91 @@ +"use strict"; + +const fs = require("fs"); + +const EARTH_RADIUS = 6371008.8; +const CURB_OFFSET_METERS = 5.2; +const MAST_REACH_METERS = 4.5; + +function buildTrafficSignals(stopLines, intersections) { + const centers = (intersections.features || []).map((feature, index) => { + const point = polygonCenter(feature.geometry); + return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) }; + }).filter((entry) => entry.point); + const signals = []; + for (const feature of stopLines.features || []) { + const center = polygonCenter(feature.geometry); + if (!center) continue; + const intersection = nearestCenter(center, centers); + if (!intersection || metersBetween(center, intersection.point) > 32) continue; + const axis = roadAxis(feature.geometry, center, intersection.point); + if (!axis) continue; + // A vehicle signal belongs beyond the junction, facing back toward the + // approaching stop line. Use the far edge of the intersection, never the + // near-side stop-line area where it would read as a pedestrian signal. + const right = [axis[1], -axis[0]]; + const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2); + // The pole is on the far-side sidewalk, not at the stop line or inside + // the intersection. Its mast then reaches back above the approach lanes. + const point = moveMeters(farSide, right, CURB_OFFSET_METERS); + signals.push({ + id: `signal-${signals.length + 1}`, + intersectionId: intersection.id, + phaseGroup: signals.length % 2, + longitude: point[0], + latitude: point[1], + headingDegrees: Math.atan2(axis[0], axis[1]) * 180 / Math.PI, + mastReachMeters: MAST_REACH_METERS, + }); + } + return { version: 1, signals }; +} + +function readTrafficSignals(stopLinePath, intersectionPath) { + return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8"))); +} + +function polygonCenter(geometry) { + const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; + if (!ring || ring.length < 4) return null; + const points = ring.slice(0, -1); + return [points.reduce((sum, point) => sum + point[0], 0) / points.length, points.reduce((sum, point) => sum + point[1], 0) / points.length]; +} + +function polygonRadius(geometry, center) { + const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; + if (!ring || !center) return 0; + return Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0); +} + +function roadAxis(geometry, center, target) { + const ring = geometry?.coordinates?.[0]; + if (!ring || ring.length < 3) return null; + let longest = null; + for (let i = 0; i < ring.length - 1; i += 1) { + const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); + const dy = ring[i + 1][1] - ring[i][1]; + const length = Math.hypot(dx, dy); + if (!longest || length > longest.length) longest = { dx, dy, length }; + } + if (!longest?.length) return null; + let axis = [-longest.dy / longest.length, longest.dx / longest.length]; + const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; + if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; + return axis; +} + +function nearestCenter(point, centers) { + return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; +} + +function metersBetween(a, b) { + const lat = (a[1] + b[1]) / 2 * Math.PI / 180; + return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; +} + +function moveMeters(point, vector, meters) { + const scale = 180 / Math.PI / EARTH_RADIUS; + return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; +} + +module.exports = { buildTrafficSignals, readTrafficSignals }; diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index ac41b61..33eb393 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -9,6 +9,7 @@ const { cesiumPreviewHtml } = require("./lib/area-preview"); const { makeVehicleGltf } = require("./lib/vehicle-model"); const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library"); const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route"); +const { buildTrafficSignals } = require("./lib/traffic-signals"); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-")); const osmPath = path.join(tempDir, "fixture.osm"); @@ -113,15 +114,41 @@ const html = cesiumPreviewHtml( "vehicle.gltf", "north<&>\u2028valley", ["car-a.gltf", "truck-a.gltf"], + "traffic-signals.json", ); assert.match(html, /north<&>\u2028valley Cesium Preview<\/title>/); assert.match(html, /Loading scene<&>\.glb/); assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/); assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/); assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/); +assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/); +assert.match(html, /id="toggleSignals"/); assert.match(html, /id="viewMode"/); assert.match(html, /data-view-mode="inspect"/); assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/); +const signals = buildTrafficSignals( + { type: "FeatureCollection", features: [ + rectangle(120.0000, 30.0000, 0.00003, 0.000006), + rectangle(120.0002, 30.0000, 0.00003, 0.000006), + ] }, + { type: "FeatureCollection", features: [ + { type: "Feature", geometry: { type: "Polygon", coordinates: [[ + [119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998], + ]] } }, + ] }, +); +assert.equal(signals.version, 1); +assert.equal(signals.signals.length, 2); +assert.deepEqual(signals.signals.map((signal) => signal.phaseGroup), [0, 1]); +assert.ok(signals.signals.every((signal) => Number.isFinite(signal.headingDegrees))); + fs.rmSync(tempDir, { recursive: true, force: true }); console.log("Preview asset tests passed."); + +function rectangle(lon, lat, halfWidth, halfHeight) { + return { type: "Feature", geometry: { type: "Polygon", coordinates: [[ + [lon - halfWidth, lat - halfHeight], [lon + halfWidth, lat - halfHeight], + [lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight], + ]] } }; +}