feat(preview): add traffic signal visualization
This commit is contained in:
@@ -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")),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
91
scripts/lib/traffic-signals.js
Normal file
91
scripts/lib/traffic-signals.js
Normal 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 };
|
||||
Reference in New Issue
Block a user