feat(preview): add traffic signal visualization

This commit is contained in:
2026-08-05 18:09:37 +08:00
parent c8ca009d13
commit 8b3a1d79d9
12 changed files with 438 additions and 7 deletions

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}

View File

@@ -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。

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}

View File

@@ -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、车辆路线和原有预览功能不受影响。

View File

@@ -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`.

View File

@@ -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": {}
}

View File

@@ -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 });

View File

@@ -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")),
};

View File

@@ -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 `<!doctype html>
<html lang="zh-CN">
@@ -53,6 +54,7 @@ function cesiumPreviewHtml(glbName, metadataName, routeName, vehicleModelName, a
<span id="semanticToggles" class="control-subgroup hidden"></span>
<label><input id="toggleRoutes" type="checkbox" checked> Routes</label>
<label><input id="toggleVehicles" type="checkbox" checked> Vehicles</label>
<label id="signalsControl"><input id="toggleSignals" type="checkbox" checked> Signals</label>
<label><input id="toggleFps" type="checkbox"> FPS</label>
<label><input id="toggleDiagnostics" type="checkbox" checked> Info</label>
</div>
@@ -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,
};
}

View File

@@ -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")

View File

@@ -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 };

View File

@@ -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, /<title>north&lt;&amp;&gt;\u2028valley Cesium Preview<\/title>/);
assert.match(html, /Loading scene&lt;&amp;&gt;\.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],
]] } };
}