feat(qgis): add editable traffic signal assemblies
This commit is contained in:
@@ -153,6 +153,7 @@ function writeDerivedConfig(area) {
|
||||
gpkg: area.outputs.gpkg,
|
||||
project: area.outputs.qgisProject,
|
||||
preview: area.outputs.qgisPreview,
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
arrowScale: area.qgis.arrowScale,
|
||||
arrowMergeTriangles: area.qgis.arrowMergeTriangles,
|
||||
arrowOutlineSimplifyMeters: area.qgis.arrowOutlineSimplifyMeters,
|
||||
@@ -200,6 +201,7 @@ function buildIntermediates(area) {
|
||||
derivedConfig: fileRecord(derivedConfigPath),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
gpkg: fileRecord(area.outputs.gpkg),
|
||||
qgisProject: fileRecord(area.outputs.qgisProject),
|
||||
@@ -207,6 +209,7 @@ function buildIntermediates(area) {
|
||||
},
|
||||
summary: {
|
||||
geojson: geojsonFeatureCounts(area),
|
||||
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
@@ -241,10 +244,12 @@ function reimportGpkg(area) {
|
||||
outputs: {
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
summary: {
|
||||
geojson: geojsonFeatureCounts(area),
|
||||
trafficSignalAssemblies: featureCount(area.outputs.trafficSignalAssemblies),
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
@@ -253,10 +258,9 @@ function reimportGpkg(area) {
|
||||
function buildBlenderScene(area) {
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
|
||||
// Traffic signal anchors are derived from the current OSM input plus the
|
||||
// normalized stop-line/intersection layers. Regenerate them for every
|
||||
// Blender build so partial runs cannot reuse a stale signal topology after
|
||||
// the source OSM has changed.
|
||||
ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies");
|
||||
// Blender consumes the editable assembly layer; OSM only initializes it in
|
||||
// intermediates, so QGIS edits remain authoritative across later stages.
|
||||
writeTrafficSignals(area);
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
|
||||
@@ -300,6 +304,7 @@ function buildBlenderScene(area) {
|
||||
osm: fileRecord(area.input),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
},
|
||||
outputs: {
|
||||
@@ -529,11 +534,7 @@ function writeCesiumPreview(area) {
|
||||
}
|
||||
|
||||
function writeTrafficSignals(area) {
|
||||
const signals = readTrafficSignals(
|
||||
path.join(area.outputs.geojsonDir, "vehicle_stop_lines.geojson"),
|
||||
path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
area.input,
|
||||
);
|
||||
const signals = readTrafficSignals(area.outputs.trafficSignalAssemblies, area.input);
|
||||
fs.writeFileSync(area.outputs.trafficSignals, `${JSON.stringify(signals, null, 2)}\n`);
|
||||
console.log(`Traffic signals: ${signals.signals.length} anchors in ${area.outputs.trafficSignals}`);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ const { execFileSync } = require("child_process");
|
||||
const { JsStreetNetwork } = require("osm2streets-js-node");
|
||||
const { qgisPaths } = require("./lib/tool-paths");
|
||||
const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows");
|
||||
const { readTrafficSignalFeatures } = require("./lib/traffic-signals");
|
||||
const {
|
||||
SCENE_LAYERS,
|
||||
AUXILIARY_EDIT_LAYERS,
|
||||
SCENE_FILE,
|
||||
SCENE_STYLE_FILE,
|
||||
layerFile,
|
||||
@@ -39,6 +41,11 @@ const clipPad = Number(config.clipPad);
|
||||
const canvasPad = Number(config.canvasPad);
|
||||
const previewPad = Number(config.previewPad);
|
||||
const layerPrefix = config.layerPrefix || "osm2streets";
|
||||
const trafficSignalLayer = AUXILIARY_EDIT_LAYERS.find((layer) => layer.id === "traffic_signal_assemblies");
|
||||
if (!trafficSignalLayer) throw new Error("Missing traffic_signal_assemblies auxiliary layer definition");
|
||||
const trafficSignalAssembliesPath = path.resolve(
|
||||
config.trafficSignalAssemblies || path.join(outDir, trafficSignalLayer.file),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(arrowScale) || arrowScale <= 0) {
|
||||
throw new Error(`Invalid arrowScale: ${config.arrowScale}`);
|
||||
@@ -116,6 +123,11 @@ fs.writeFileSync(
|
||||
for (const layer of SCENE_LAYERS) {
|
||||
writeJson(path.join(outDir, layerFile(layer)), split[layer.splitKey]);
|
||||
}
|
||||
writeJson(trafficSignalAssembliesPath, readTrafficSignalFeatures(
|
||||
path.join(outDir, "vehicle_stop_lines.geojson"),
|
||||
path.join(outDir, "intersection_surface.geojson"),
|
||||
inputPath,
|
||||
));
|
||||
if (arrowMergeTriangles) {
|
||||
normalizeLaneArrows(path.join(outDir, "lane_arrows_webscale.geojson"), arrowOutlineSimplifyMeters);
|
||||
split.laneArrows = JSON.parse(fs.readFileSync(path.join(outDir, "lane_arrows_webscale.geojson"), "utf8"));
|
||||
@@ -134,6 +146,7 @@ const ogrEnv = qgis.env;
|
||||
SCENE_LAYERS.forEach((layer, index) => {
|
||||
importLayer(gpkgPath, path.join(outDir, layerFile(layer)), layer.id, index > 0, ogrEnv);
|
||||
});
|
||||
importLayer(gpkgPath, trafficSignalAssembliesPath, trafficSignalLayer.id, true, ogrEnv);
|
||||
|
||||
const qgisScript = path.join(outDir, "_create_qgis_project.py");
|
||||
const previewFeature = split.crosswalks.features[0] || split.laneArrows.features[0] || split.roadSurface.features[0];
|
||||
@@ -149,6 +162,7 @@ fs.writeFileSync(qgisScript, makeQgisScript({
|
||||
layerPrefix,
|
||||
canvasExtent: config.canvasExtent || extentString(expandBounds(bbox, canvasPad)),
|
||||
previewExtent: config.previewExtent || defaultPreviewExtent,
|
||||
trafficSignalSymbolPath: path.join(repoRoot, "assets", "qgis", "traffic-signal-direction.svg"),
|
||||
}));
|
||||
|
||||
execFileSync(qgisPython, [qgisScript], {
|
||||
@@ -1437,16 +1451,24 @@ from qgis.PyQt.QtGui import QColor, QImage, QPainter
|
||||
from qgis.core import (
|
||||
QgsApplication,
|
||||
QgsCoordinateReferenceSystem,
|
||||
QgsEditorWidgetSetup,
|
||||
QgsFillSymbol,
|
||||
QgsMarkerSymbol,
|
||||
QgsMapRendererCustomPainterJob,
|
||||
QgsMapSettings,
|
||||
QgsProject,
|
||||
QgsPalLayerSettings,
|
||||
QgsProperty,
|
||||
QgsRectangle,
|
||||
QgsSingleSymbolRenderer,
|
||||
QgsSymbolLayer,
|
||||
QgsSvgMarkerSymbolLayer,
|
||||
QgsVectorLayerSimpleLabeling,
|
||||
QgsVectorLayer,
|
||||
)
|
||||
|
||||
QGIS_PREFIX = ${JSON.stringify(options.qgisPrefix)}
|
||||
TRAFFIC_SIGNAL_SYMBOL = ${JSON.stringify(options.trafficSignalSymbolPath)}
|
||||
GPKG = ${JSON.stringify(options.gpkgPath)}
|
||||
PROJECT_PATH = ${JSON.stringify(options.projectPath)}
|
||||
PREVIEW_PATH = ${JSON.stringify(options.previewPath)}
|
||||
@@ -1475,6 +1497,38 @@ def make_layer(layer_name, title, color, outline="0,0,0,0", outline_width="0"):
|
||||
layer.setRenderer(QgsSingleSymbolRenderer(fill_symbol(color, outline, outline_width)))
|
||||
return layer
|
||||
|
||||
def make_signal_layer():
|
||||
layer = QgsVectorLayer(f"{GPKG}|layername=traffic_signal_assemblies", f"{LAYER_PREFIX} traffic signal assemblies", "ogr")
|
||||
if not layer.isValid():
|
||||
raise RuntimeError("Invalid traffic signal assemblies layer")
|
||||
symbol = QgsMarkerSymbol()
|
||||
svg_layer = QgsSvgMarkerSymbolLayer(TRAFFIC_SIGNAL_SYMBOL, 9)
|
||||
svg_layer.setDataDefinedProperty(
|
||||
QgsSymbolLayer.Property.Angle,
|
||||
QgsProperty.fromField("heading_deg"),
|
||||
)
|
||||
symbol.changeSymbolLayer(0, svg_layer)
|
||||
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
|
||||
labels = QgsPalLayerSettings()
|
||||
labels.fieldName = "if(trim(display_id) = '', signal_uid, display_id)"
|
||||
labels.isExpression = True
|
||||
layer.setLabeling(QgsVectorLayerSimpleLabeling(labels))
|
||||
layer.setLabelsEnabled(True)
|
||||
for field_name in ("signal_uid", "control_id", "approach_id", "source_way_id", "stop_lon", "stop_lat"):
|
||||
index = layer.fields().indexOf(field_name)
|
||||
if index >= 0:
|
||||
layer.setFieldConstraint(index, 1)
|
||||
form = layer.editFormConfig()
|
||||
form.setReadOnly(index, True)
|
||||
layer.setEditFormConfig(form)
|
||||
enabled_index = layer.fields().indexOf("enabled")
|
||||
if enabled_index >= 0:
|
||||
layer.setEditorWidgetSetup(enabled_index, QgsEditorWidgetSetup("CheckBox", {"CheckedState": "1", "UncheckedState": "0"}))
|
||||
phase_index = layer.fields().indexOf("phase_group")
|
||||
if phase_index >= 0:
|
||||
layer.setEditorWidgetSetup(phase_index, QgsEditorWidgetSetup("ValueMap", {"map": [{"Phase 0": 0}, {"Phase 1": 1}]}))
|
||||
return layer
|
||||
|
||||
QgsApplication.setPrefixPath(QGIS_PREFIX, True)
|
||||
app = QgsApplication([], False)
|
||||
app.initQgis()
|
||||
@@ -1495,12 +1549,16 @@ layers = {
|
||||
)
|
||||
for spec in LAYER_SPECS
|
||||
}
|
||||
signal_layer = make_signal_layer()
|
||||
layers["traffic_signal_assemblies"] = signal_layer
|
||||
draw_order = [spec["id"] for spec in LAYER_SPECS]
|
||||
for key in draw_order:
|
||||
project.addMapLayer(layers[key], False)
|
||||
project.addMapLayer(signal_layer, False)
|
||||
root = project.layerTreeRoot()
|
||||
for key in draw_order:
|
||||
root.insertLayer(0, layers[key])
|
||||
root.insertLayer(0, signal_layer)
|
||||
if not project.write(PROJECT_PATH):
|
||||
raise RuntimeError(f"Failed to write {PROJECT_PATH}")
|
||||
|
||||
|
||||
@@ -58,8 +58,11 @@ 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`)),
|
||||
// Signals are an auxiliary intermediates artifact shared by Blender and
|
||||
// the browser preview. They deliberately are not one of the QGIS layers.
|
||||
trafficSignalAssemblies: path.resolve(
|
||||
outputOverrides.trafficSignalAssemblies || path.join(geojsonDir, "traffic_signal_assemblies.geojson"),
|
||||
),
|
||||
// Runtime poses are derived from the editable assembly layer and shared by
|
||||
// Blender and the browser preview.
|
||||
trafficSignals: path.resolve(outputOverrides.trafficSignals || path.join(geojsonDir, "traffic_signals.json")),
|
||||
pipelineDir,
|
||||
stageManifestDir: path.resolve(outputOverrides.stageManifestDir || path.join(pipelineDir, "stages")),
|
||||
|
||||
@@ -327,6 +327,8 @@ function artifactStatus(area) {
|
||||
["GeoPackage", area.outputs.gpkg, true, "file"],
|
||||
["QGIS project", area.outputs.qgisProject, true, "file"],
|
||||
["QGIS preview", area.outputs.qgisPreview, true, "file"],
|
||||
["Traffic signal assemblies", area.outputs.trafficSignalAssemblies, true, "file"],
|
||||
["Traffic signal runtime", area.outputs.trafficSignals, true, "file"],
|
||||
["Blend scene", area.outputs.blend, true, "file"],
|
||||
["Render PNG", area.outputs.render, true, "file"],
|
||||
["Cesium GLB", area.outputs.glb, true, "file"],
|
||||
@@ -400,6 +402,8 @@ function stageManifestStatus(area, configPath = null) {
|
||||
derivedConfig,
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.trafficSignals,
|
||||
gpkg: area.outputs.gpkg,
|
||||
qgisProject: area.outputs.qgisProject,
|
||||
qgisPreview: optionalExpectedFile(area.outputs.qgisPreview),
|
||||
@@ -416,6 +420,8 @@ function stageManifestStatus(area, configPath = null) {
|
||||
outputs: {
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.trafficSignals,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -426,6 +432,8 @@ function stageManifestStatus(area, configPath = null) {
|
||||
osm: area.input,
|
||||
geojsonDir: area.outputs.geojsonDir,
|
||||
...sceneGeojsonFiles(area),
|
||||
trafficSignalAssemblies: area.outputs.trafficSignalAssemblies,
|
||||
trafficSignals: area.outputs.trafficSignals,
|
||||
},
|
||||
outputs: {
|
||||
blend: area.outputs.blend,
|
||||
|
||||
@@ -514,11 +514,12 @@
|
||||
let changed = false;
|
||||
const groupPhases = new Map();
|
||||
for (const signal of signals) {
|
||||
const nodeKey = signal.nodeKey || signal.id;
|
||||
const phase = signalPhase(signal.phaseGroup, phaseTime, start);
|
||||
groupPhases.set(signal.phaseGroup, phase.active);
|
||||
if (signal === signals[0]) state.phase = `${phase.active} ${String(phase.remaining).padStart(2, "0")}`;
|
||||
for (const state of ["red", "yellow", "green"]) {
|
||||
const value = node(`TrafficSignalDynamic_${signal.id}_${state}`);
|
||||
const value = node(`TrafficSignalDynamic_${nodeKey}_${state}`);
|
||||
if (value && value.show !== (state === phase.active)) {
|
||||
value.show = state === phase.active;
|
||||
changed = true;
|
||||
@@ -527,7 +528,7 @@
|
||||
const visibleCountdown = String(phase.remaining).padStart(2, "0");
|
||||
const countdownModel = countdownModels.get(Number(signal.phaseGroup));
|
||||
for (let value = 0; value < 20; value += 1) {
|
||||
const name = `TrafficSignalDynamic_${signal.id}_countdown_${String(value).padStart(2, "0")}`;
|
||||
const name = `TrafficSignalDynamic_${nodeKey}_countdown_${String(value).padStart(2, "0")}`;
|
||||
let countdown = null;
|
||||
countdown = countdownNode(countdownModel, name);
|
||||
if (countdown && countdown.show !== (String(value).padStart(2, "0") === visibleCountdown)) {
|
||||
|
||||
@@ -44,7 +44,11 @@ function parseOsm(xml) {
|
||||
const neighbor = way.refs[neighborIndex];
|
||||
if (!neighbor || !nodes.has(neighbor)) continue;
|
||||
const neighborPoint = nodes.get(neighbor);
|
||||
arms.push({ headingDegrees: headingBetween(control, neighborPoint), wayId: way.id });
|
||||
arms.push({
|
||||
headingDegrees: headingBetween(control, neighborPoint),
|
||||
wayId: String(way.id),
|
||||
neighborNodeId: String(neighbor),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,17 @@ const SCENE_LAYERS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Editable control layers share the GeoPackage/QGIS lifecycle but never enter
|
||||
// the merged render scene or its draw order.
|
||||
const AUXILIARY_EDIT_LAYERS = [
|
||||
{
|
||||
id: "traffic_signal_assemblies",
|
||||
file: "traffic_signal_assemblies.geojson",
|
||||
title: "traffic signal assemblies",
|
||||
geometry: "Point",
|
||||
},
|
||||
];
|
||||
|
||||
const SCENE_FILE = "osm2streets_scene.geojson";
|
||||
const SCENE_STYLE_FILE = "osm2streets_scene_style.json";
|
||||
|
||||
@@ -155,6 +166,7 @@ function qgisRgba(hex, alpha = 255) {
|
||||
|
||||
module.exports = {
|
||||
SCENE_LAYERS,
|
||||
AUXILIARY_EDIT_LAYERS,
|
||||
SCENE_FILE,
|
||||
SCENE_STYLE_FILE,
|
||||
layerFile,
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { parseOsm } = require("./osm");
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const CURB_OFFSET_METERS = 5.2;
|
||||
const MAST_REACH_METERS = 4.5;
|
||||
// This layout is serialized with the anchors so Blender's static structure and
|
||||
// Cesium's dynamic overlay cannot independently drift in size or handedness.
|
||||
// Lateral offsets use the approach travel direction: positive is the driver's
|
||||
// right. The countdown board therefore sits at +1.15m from the signal head.
|
||||
const SIGNAL_LAYOUT = Object.freeze({
|
||||
poleHeightMeters: 6.7,
|
||||
poleRadiusMeters: 0.13,
|
||||
armWidthMeters: 0.21,
|
||||
// The mast arm and the signal head share this centre elevation.
|
||||
mastHeightMeters: 6.25,
|
||||
headCenterHeightMeters: 6.25,
|
||||
headWidthMeters: 0.68,
|
||||
headDepthMeters: 0.30,
|
||||
headBodyHeightMeters: 1.62,
|
||||
lensRadiusMeters: 0.22,
|
||||
lensDepthMeters: 0.07,
|
||||
lensFaceOffsetMeters: 0.18,
|
||||
poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21,
|
||||
mastHeightMeters: 6.25, headCenterHeightMeters: 6.25,
|
||||
headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62,
|
||||
lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18,
|
||||
lensVerticalOffsetsMeters: [0.49, -0.01, -0.51],
|
||||
countdownLateralMeters: 1.15,
|
||||
countdownFaceOffsetMeters: 0.05,
|
||||
countdownWidthMeters: 0.82,
|
||||
countdownDepthMeters: 0.14,
|
||||
countdownHeightMeters: 0.56,
|
||||
// The countdown board is fixed on the mast arm, not hung below it.
|
||||
countdownVerticalOffsetMeters: 0.0,
|
||||
countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05,
|
||||
countdownWidthMeters: 0.82, countdownDepthMeters: 0.14,
|
||||
countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0,
|
||||
});
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections, controls = []) {
|
||||
function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
|
||||
const centers = (intersections.features || []).map((feature, index) => {
|
||||
const point = polygonCenter(feature.geometry);
|
||||
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
|
||||
@@ -46,215 +31,238 @@ function buildTrafficSignals(stopLines, intersections, controls = []) {
|
||||
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);
|
||||
candidates.push({
|
||||
intersectionId: intersection.id,
|
||||
center,
|
||||
axis,
|
||||
point,
|
||||
intersectionId: intersection.id, center, axis,
|
||||
point: moveMeters(farSide, right, CURB_OFFSET_METERS),
|
||||
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
|
||||
});
|
||||
}
|
||||
const signals = [];
|
||||
const features = [];
|
||||
for (const control of controls) {
|
||||
const controlPoint = [Number(control.longitude), Number(control.latitude)];
|
||||
if (!controlPoint.every(Number.isFinite)) continue;
|
||||
// A traffic-signal node on a through road is not a controlled vehicle
|
||||
// junction. Its connected motor-road arms are the source of truth.
|
||||
if (!Array.isArray(control.arms) || control.arms.length < 3) continue;
|
||||
if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue;
|
||||
const intersection = nearestCenter(controlPoint, centers);
|
||||
if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue;
|
||||
const arms = matchOsmArms(candidates.filter((candidate) => candidate.intersectionId === intersection.id), controlPoint, control.arms);
|
||||
const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms);
|
||||
const groups = phaseGroups(arms);
|
||||
for (const [index, candidate] of arms.entries()) {
|
||||
signals.push({
|
||||
id: `signal-${signals.length + 1}`,
|
||||
controlId: String(control.id || ""),
|
||||
intersectionId: intersection.id,
|
||||
phaseGroup: groups[index],
|
||||
longitude: candidate.point[0],
|
||||
latitude: candidate.point[1],
|
||||
stopLongitude: candidate.center[0],
|
||||
stopLatitude: candidate.center[1],
|
||||
headingDegrees: candidate.headingDegrees,
|
||||
mastReachMeters: MAST_REACH_METERS,
|
||||
pose: buildSignalPose(candidate.point, candidate.axis, MAST_REACH_METERS),
|
||||
arms.forEach((candidate, index) => {
|
||||
const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`;
|
||||
const sourceWayId = String(candidate.osmArm?.wayId || "legacy");
|
||||
const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId);
|
||||
const approachId = `${sourceWayId}:${neighborNodeId}`;
|
||||
const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`;
|
||||
features.push({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: candidate.point.slice() },
|
||||
properties: {
|
||||
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
|
||||
approach_id: approachId, source_way_id: sourceWayId,
|
||||
heading_deg: candidate.headingDegrees, phase_group: groups[index],
|
||||
mast_reach_m: MAST_REACH_METERS,
|
||||
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
|
||||
enabled: true, z_offset_m: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return validateTrafficSignalFeatures({ type: "FeatureCollection", features });
|
||||
}
|
||||
|
||||
function validateTrafficSignalFeatures(collection) {
|
||||
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
|
||||
throw new Error("Traffic signal assemblies must be a FeatureCollection");
|
||||
}
|
||||
const uids = new Set();
|
||||
const displayIds = new Set();
|
||||
const features = collection.features.map((feature, index) => {
|
||||
const label = `traffic signal feature ${index + 1}`;
|
||||
if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) ||
|
||||
feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) {
|
||||
throw new Error(`${label}: geometry must be a finite Point`);
|
||||
}
|
||||
const input = feature.properties || {};
|
||||
const text = (key, required = true) => {
|
||||
const value = input[key] == null ? "" : String(input[key]).trim();
|
||||
if (required && !value) throw new Error(`${label}: missing ${key}`);
|
||||
return value;
|
||||
};
|
||||
const number = (key, options = {}) => {
|
||||
if (input[key] === null || input[key] === undefined || input[key] === "") {
|
||||
throw new Error(`${label}: missing ${key}`);
|
||||
}
|
||||
const value = Number(input[key]);
|
||||
if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) {
|
||||
throw new Error(`${label}: invalid ${key} '${input[key]}'`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const signalUid = text("signal_uid");
|
||||
if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`);
|
||||
if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`);
|
||||
uids.add(signalUid);
|
||||
const displayId = text("display_id", false);
|
||||
if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`);
|
||||
if (displayId) displayIds.add(displayId);
|
||||
const phaseGroup = number("phase_group", { min: 0, max: 1 });
|
||||
if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`);
|
||||
const enabled = normalizeBoolean(input.enabled, label);
|
||||
const controlId = text("control_id");
|
||||
const approachId = text("approach_id");
|
||||
const sourceWayId = text("source_way_id");
|
||||
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
|
||||
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
|
||||
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
|
||||
return {
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
|
||||
properties: {
|
||||
...input, signal_uid: signalUid, display_id: displayId,
|
||||
control_id: controlId, approach_id: approachId,
|
||||
source_way_id: sourceWayId, heading_deg: normalizeDegrees(number("heading_deg")),
|
||||
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
|
||||
stop_lon: number("stop_lon", { min: -180, max: 180 }),
|
||||
stop_lat: number("stop_lat", { min: -90, max: 90 }),
|
||||
enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { type: "FeatureCollection", features };
|
||||
}
|
||||
|
||||
function buildTrafficSignalsFromFeatures(collection) {
|
||||
const normalized = validateTrafficSignalFeatures(collection);
|
||||
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
|
||||
const p = feature.properties;
|
||||
const point = feature.geometry.coordinates;
|
||||
const axis = headingVector(p.heading_deg);
|
||||
return {
|
||||
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
|
||||
nodeKey: signalNodeKey(p.signal_uid),
|
||||
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
|
||||
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
|
||||
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
|
||||
headingDegrees: p.heading_deg, mastReachMeters: p.mast_reach_m,
|
||||
zOffsetMeters: p.z_offset_m,
|
||||
pose: buildSignalPose(point, axis, p.mast_reach_m, p.z_offset_m),
|
||||
};
|
||||
});
|
||||
return { version: 3, layout: SIGNAL_LAYOUT, signals };
|
||||
}
|
||||
|
||||
function uniqueApproachArms(candidates, controlPoint) {
|
||||
const sorted = candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)),
|
||||
controlDistance: metersBetween(controlPoint, candidate.center),
|
||||
})).sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
|
||||
const arms = [];
|
||||
for (const candidate of sorted) {
|
||||
const duplicate = arms.find((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25);
|
||||
if (!duplicate) arms.push(candidate);
|
||||
function signalNodeKey(signalUid) {
|
||||
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function validateTrafficSignalSourceReferences(collection, controls) {
|
||||
const normalized = validateTrafficSignalFeatures(collection);
|
||||
const approachesByControl = new Map((controls || []).map((control) => [
|
||||
String(control.id),
|
||||
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
|
||||
]));
|
||||
for (const [index, feature] of normalized.features.entries()) {
|
||||
const { control_id: controlId, approach_id: approachId } = feature.properties;
|
||||
const approaches = approachesByControl.get(controlId);
|
||||
if (!approaches) {
|
||||
throw new Error(`traffic signal feature ${index + 1}: control_id '${controlId}' is not present in the current OSM`);
|
||||
}
|
||||
if (!approaches.has(approachId)) {
|
||||
throw new Error(
|
||||
`traffic signal feature ${index + 1}: approach_id '${approachId}' is not present on OSM control '${controlId}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildTrafficSignals(stopLines, intersections, controls = []) {
|
||||
return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls));
|
||||
}
|
||||
|
||||
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
|
||||
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||
return buildTrafficSignalFeatures(
|
||||
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
|
||||
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
|
||||
);
|
||||
}
|
||||
|
||||
function readTrafficSignals(editablePath, osmPath = null) {
|
||||
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
|
||||
if (osmPath) {
|
||||
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||
validateTrafficSignalSourceReferences(collection, controls);
|
||||
}
|
||||
return buildTrafficSignalsFromFeatures(collection);
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, label) {
|
||||
if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true;
|
||||
if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false;
|
||||
throw new Error(`${label}: invalid enabled '${value}'`);
|
||||
}
|
||||
|
||||
function uniqueApproachArms(candidates, controlPoint) {
|
||||
const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) }))
|
||||
.sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance);
|
||||
const arms = [];
|
||||
for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate);
|
||||
return arms;
|
||||
}
|
||||
|
||||
function matchOsmArms(candidates, controlPoint, osmArms) {
|
||||
const withHeadings = candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)),
|
||||
}));
|
||||
if (!Array.isArray(osmArms) || !osmArms.length) return uniqueApproachArms(withHeadings, controlPoint);
|
||||
const remaining = withHeadings.slice();
|
||||
const matched = [];
|
||||
for (const osmArm of osmArms) {
|
||||
let bestIndex = -1;
|
||||
let bestDistance = Infinity;
|
||||
for (let index = 0; index < remaining.length; index += 1) {
|
||||
const distance = angularDistance(remaining[index].armHeading, osmArm.headingDegrees);
|
||||
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
|
||||
}
|
||||
if (bestIndex >= 0 && bestDistance <= 45) {
|
||||
matched.push(remaining.splice(bestIndex, 1)[0]);
|
||||
} else {
|
||||
matched.push(fallbackCandidate(controlPoint, osmArm));
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
|
||||
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
|
||||
return osmArms.map((osmArm) => {
|
||||
let bestIndex = -1; let bestDistance = Infinity;
|
||||
remaining.forEach((item, index) => { const distance = angularDistance(item.armHeading, osmArm.headingDegrees); if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } });
|
||||
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
|
||||
return { ...candidate, osmArm };
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackCandidate(controlPoint, osmArm) {
|
||||
const outward = headingVector(osmArm.headingDegrees);
|
||||
const axis = [-outward[0], -outward[1]];
|
||||
const stopDistance = 8.0;
|
||||
const stop = moveMeters(controlPoint, outward, stopDistance);
|
||||
const farSide = moveMeters(controlPoint, axis, 3.2);
|
||||
return {
|
||||
center: stop,
|
||||
axis,
|
||||
point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS),
|
||||
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
|
||||
fallback: true,
|
||||
};
|
||||
const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]];
|
||||
const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2);
|
||||
return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true };
|
||||
}
|
||||
|
||||
function phaseGroups(arms) {
|
||||
const groups = Array(arms.length).fill(1);
|
||||
if (arms.length < 2) return groups;
|
||||
let main = [0, 1];
|
||||
let bestOpposition = -1;
|
||||
for (let left = 0; left < arms.length; left += 1) {
|
||||
for (let right = left + 1; right < arms.length; right += 1) {
|
||||
const opposition = angularDistance(arms[left].armHeading, arms[right].armHeading);
|
||||
if (opposition > bestOpposition) {
|
||||
bestOpposition = opposition;
|
||||
main = [left, right];
|
||||
}
|
||||
}
|
||||
}
|
||||
groups[main[0]] = 0;
|
||||
groups[main[1]] = 0;
|
||||
return groups;
|
||||
const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups;
|
||||
let main = [0, 1]; let best = -1;
|
||||
for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } }
|
||||
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
|
||||
}
|
||||
|
||||
function buildSignalPose(pole, axis, mastReach) {
|
||||
const lateral = [axis[1], -axis[0]];
|
||||
const face = [-axis[0], -axis[1]];
|
||||
const head = moveMeters(pole, lateral, -mastReach);
|
||||
const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
|
||||
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height });
|
||||
function buildSignalPose(pole, axis, mastReach, zOffset = 0) {
|
||||
const lateral = [axis[1], -axis[0]]; const face = [-axis[0], -axis[1]];
|
||||
const head = moveMeters(pole, lateral, -mastReach); const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
|
||||
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset });
|
||||
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
|
||||
const board = moveMeters(
|
||||
moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters),
|
||||
face, SIGNAL_LAYOUT.countdownFaceOffsetMeters,
|
||||
);
|
||||
return {
|
||||
pole: position(pole, 0),
|
||||
arm: {
|
||||
from: position(pole, SIGNAL_LAYOUT.mastHeightMeters),
|
||||
to: position(head, SIGNAL_LAYOUT.mastHeightMeters),
|
||||
},
|
||||
head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees },
|
||||
lenses: ["red", "yellow", "green"].map((state, index) => ({
|
||||
state,
|
||||
...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]),
|
||||
})),
|
||||
countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees },
|
||||
};
|
||||
const board = moveMeters(moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
|
||||
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
|
||||
}
|
||||
|
||||
function readTrafficSignals(stopLinePath, intersectionPath, osmPath) {
|
||||
const controls = osmPath ? parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls : [];
|
||||
return buildTrafficSignals(JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls);
|
||||
}
|
||||
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((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; }
|
||||
function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; }
|
||||
function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; 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]; }
|
||||
function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; }
|
||||
function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }
|
||||
function normalizeDegrees(value) { return ((value % 360) + 360) % 360; }
|
||||
function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); }
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
function headingBetween(from, to) {
|
||||
const latitude = (from[1] + to[1]) / 2 * Math.PI / 180;
|
||||
return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
function headingVector(headingDegrees) {
|
||||
const radians = headingDegrees * Math.PI / 180;
|
||||
return [Math.sin(radians), Math.cos(radians)];
|
||||
}
|
||||
|
||||
function normalizeDegrees(value) {
|
||||
return ((value % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function angularDistance(a, b) {
|
||||
return Math.abs(((a - b + 540) % 360) - 180);
|
||||
}
|
||||
|
||||
module.exports = { SIGNAL_LAYOUT, buildTrafficSignals, readTrafficSignals };
|
||||
module.exports = {
|
||||
SIGNAL_LAYOUT,
|
||||
signalNodeKey,
|
||||
buildTrafficSignalFeatures,
|
||||
validateTrafficSignalFeatures,
|
||||
validateTrafficSignalSourceReferences,
|
||||
buildTrafficSignalsFromFeatures,
|
||||
buildTrafficSignals,
|
||||
readTrafficSignalFeatures,
|
||||
readTrafficSignals,
|
||||
};
|
||||
|
||||
@@ -17,8 +17,13 @@ const path = require("path");
|
||||
const os = require("os");
|
||||
const { execFileSync } = require("child_process");
|
||||
const { qgisPaths } = require("./lib/tool-paths");
|
||||
const { parseOsm } = require("./lib/osm");
|
||||
const {
|
||||
validateTrafficSignalSourceReferences,
|
||||
} = require("./lib/traffic-signals");
|
||||
const {
|
||||
SCENE_LAYERS,
|
||||
AUXILIARY_EDIT_LAYERS,
|
||||
SCENE_FILE,
|
||||
SCENE_STYLE_FILE,
|
||||
layerFile,
|
||||
@@ -34,6 +39,10 @@ const ogr2ogr = qgis.ogr2ogr;
|
||||
const ogrinfo = qgis.ogrinfo;
|
||||
const outDir = path.resolve(requireText(config.outDir, "outDir"));
|
||||
const gpkgPath = path.resolve(requireText(config.gpkg, "gpkg"));
|
||||
const inputPath = path.resolve(requireText(config.input, "input"));
|
||||
const trafficSignalAssembliesPath = path.resolve(
|
||||
config.trafficSignalAssemblies || path.join(outDir, "traffic_signal_assemblies.geojson"),
|
||||
);
|
||||
|
||||
for (const exe of [ogr2ogr, ogrinfo]) {
|
||||
if (!fs.existsSync(exe)) {
|
||||
@@ -43,6 +52,9 @@ for (const exe of [ogr2ogr, ogrinfo]) {
|
||||
if (!fs.existsSync(gpkgPath)) {
|
||||
throw new Error(`GeoPackage not found: ${gpkgPath}\nRun the intermediates stage first.`);
|
||||
}
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
throw new Error(`Input OSM XML not found: ${inputPath}`);
|
||||
}
|
||||
if (!fs.existsSync(outDir)) {
|
||||
throw new Error(`GeoJSON output directory not found: ${outDir}`);
|
||||
}
|
||||
@@ -51,6 +63,7 @@ console.log(`Reimport: ${gpkgPath}`);
|
||||
console.log(`Target: ${outDir}`);
|
||||
|
||||
const present = gpkgLayers();
|
||||
const trafficSignalControls = parseOsm(fs.readFileSync(inputPath, "utf8")).trafficSignalControls;
|
||||
const missing = SCENE_LAYERS.filter((layer) => !present.has(layer.id)).map((layer) => layer.id);
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
@@ -68,11 +81,25 @@ try {
|
||||
console.log(`${layer.id}\tfeatures=${collection.features.length}`);
|
||||
return { layer, stagedPath, collection };
|
||||
});
|
||||
const auxiliary = AUXILIARY_EDIT_LAYERS.map((layer) => {
|
||||
if (!present.has(layer.id)) throw new Error(`GeoPackage is missing auxiliary layer '${layer.id}'`);
|
||||
const stagedPath = path.join(stagingDir, layer.file);
|
||||
exportLayer(layer.id, stagedPath);
|
||||
const collection = readCollection(stagedPath, layer.id);
|
||||
const validated = validateTrafficSignalSourceReferences(collection, trafficSignalControls);
|
||||
console.log(`${layer.id}\tfeatures=${validated.features.length}`);
|
||||
return { layer, stagedPath, collection: validated };
|
||||
});
|
||||
|
||||
for (const item of staged) {
|
||||
// Copy rather than rename: the staging dir may be on another filesystem.
|
||||
fs.copyFileSync(item.stagedPath, path.join(outDir, layerFile(item.layer)));
|
||||
}
|
||||
for (const item of auxiliary) {
|
||||
const destination = item.layer.id === "traffic_signal_assemblies"
|
||||
? trafficSignalAssembliesPath : path.join(outDir, item.layer.file);
|
||||
fs.copyFileSync(item.stagedPath, destination);
|
||||
}
|
||||
|
||||
const byId = new Map(staged.map((item) => [item.layer.id, item.collection]));
|
||||
const scene = mergeScene((layer) => byId.get(layer.id));
|
||||
@@ -117,7 +144,7 @@ function loadConfig(cliArgs) {
|
||||
}
|
||||
Object.assign(base, JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
for (const key of ["qgisApp", "outDir", "gpkg"]) {
|
||||
for (const key of ["qgisApp", "input", "outDir", "gpkg", "trafficSignalAssemblies"]) {
|
||||
if (cliArgs[key] !== undefined) base[key] = cliArgs[key];
|
||||
}
|
||||
return base;
|
||||
|
||||
@@ -40,6 +40,10 @@ assert.equal(
|
||||
normalizeAreaConfig(base).outputs.trafficSignals,
|
||||
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signals.json"),
|
||||
);
|
||||
assert.equal(
|
||||
normalizeAreaConfig(base).outputs.trafficSignalAssemblies,
|
||||
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"),
|
||||
);
|
||||
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
|
||||
|
||||
@@ -135,7 +135,7 @@ assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
|
||||
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
|
||||
assert.match(previewRuntime, /asset\.category === "dynamic"/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_/);
|
||||
assert.match(previewRuntime, /countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
||||
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
|
||||
assert.match(previewRuntime, /asset\.category === "countdown"/);
|
||||
assert.doesNotMatch(previewRuntime, /createCountdownDigits/);
|
||||
|
||||
113
scripts/test-traffic-signals.js
Normal file
113
scripts/test-traffic-signals.js
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const {
|
||||
buildTrafficSignalFeatures,
|
||||
buildTrafficSignalsFromFeatures,
|
||||
validateTrafficSignalFeatures,
|
||||
validateTrafficSignalSourceReferences,
|
||||
} = require("./lib/traffic-signals");
|
||||
|
||||
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
|
||||
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
||||
[lon - dx, lat - dy], [lon + dx, lat - dy], [lon + dx, lat + dy],
|
||||
[lon - dx, lat + dy], [lon - dx, lat - dy],
|
||||
]] }, properties: {} };
|
||||
}
|
||||
|
||||
const intersections = { type: "FeatureCollection", features: [rectangle(120.0001, 30.00005, 0.0003, 0.00025)] };
|
||||
const stops = { type: "FeatureCollection", features: [
|
||||
rectangle(119.99995, 30.00005), rectangle(120.00010, 30.00025),
|
||||
rectangle(120.00035, 30.00005), rectangle(120.00010, 29.99985),
|
||||
] };
|
||||
const arms = [
|
||||
{ headingDegrees: 270, wayId: "west", neighborNodeId: "w1" },
|
||||
{ headingDegrees: 0, wayId: "north", neighborNodeId: "n1" },
|
||||
{ headingDegrees: 90, wayId: "east", neighborNodeId: "e1" },
|
||||
{ headingDegrees: 180, wayId: "south", neighborNodeId: "s1" },
|
||||
];
|
||||
const control = { id: "control-1", longitude: 120.0001, latitude: 30.00005, arms };
|
||||
|
||||
const cross = buildTrafficSignalFeatures(stops, intersections, [control]);
|
||||
assert.equal(cross.features.length, 4);
|
||||
assert.equal(new Set(cross.features.map((feature) => feature.properties.signal_uid)).size, 4);
|
||||
const t = buildTrafficSignalFeatures(stops, intersections, [{ ...control, arms: arms.slice(0, 3) }]);
|
||||
assert.equal(t.features.length, 3);
|
||||
assert.deepEqual(
|
||||
buildTrafficSignalFeatures(stops, intersections, [control]).features.map((feature) => feature.properties.signal_uid),
|
||||
cross.features.map((feature) => feature.properties.signal_uid),
|
||||
"technical ids are deterministic",
|
||||
);
|
||||
|
||||
const edited = structuredClone(cross);
|
||||
const first = edited.features[0];
|
||||
const originalStop = [first.properties.stop_lon, first.properties.stop_lat];
|
||||
first.geometry.coordinates[0] += 0.0001;
|
||||
first.properties.display_id = "A-01";
|
||||
first.properties.heading_deg = 42;
|
||||
first.properties.z_offset_m = 1.25;
|
||||
const runtime = buildTrafficSignalsFromFeatures(edited);
|
||||
assert.equal(new Set(runtime.signals.map((signal) => signal.nodeKey)).size, runtime.signals.length);
|
||||
for (const signal of runtime.signals) {
|
||||
assert.match(signal.nodeKey, /^ts_[0-9a-f]{16}$/);
|
||||
assert.ok(
|
||||
`TrafficSignalDynamic_${signal.nodeKey}_countdown_19`.length <= 63,
|
||||
"dynamic node names must stay below Blender's name limit",
|
||||
);
|
||||
}
|
||||
const changed = runtime.signals.find((signal) => signal.id === first.properties.signal_uid);
|
||||
assert.equal(changed.displayId, "A-01");
|
||||
assert.equal(changed.longitude, first.geometry.coordinates[0]);
|
||||
assert.equal(changed.headingDegrees, 42);
|
||||
assert.deepEqual([changed.stopLongitude, changed.stopLatitude], originalStop, "moving a pole preserves the stop point");
|
||||
assert.equal(changed.pose.pole.height, 1.25);
|
||||
assert.equal(changed.pose.arm.from.height, 7.5);
|
||||
|
||||
edited.features[1].properties.enabled = "0";
|
||||
assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted");
|
||||
|
||||
const duplicateUid = structuredClone(cross);
|
||||
duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.signal_uid;
|
||||
assert.throws(() => validateTrafficSignalFeatures(duplicateUid), /Duplicate signal_uid/);
|
||||
const duplicateDisplay = structuredClone(cross);
|
||||
duplicateDisplay.features[1].properties.display_id = duplicateDisplay.features[0].properties.display_id;
|
||||
assert.throws(() => validateTrafficSignalFeatures(duplicateDisplay), /Duplicate display_id/);
|
||||
const invalid = structuredClone(cross);
|
||||
invalid.features[0].properties.mast_reach_m = -1;
|
||||
assert.throws(() => validateTrafficSignalFeatures(invalid), /invalid mast_reach_m/);
|
||||
const invalidGeometry = structuredClone(cross);
|
||||
invalidGeometry.features[0].geometry = { type: "LineString", coordinates: [[120, 30], [121, 31]] };
|
||||
assert.throws(() => validateTrafficSignalFeatures(invalidGeometry), /geometry must be a finite Point/);
|
||||
const mismatchedIdentity = structuredClone(cross);
|
||||
mismatchedIdentity.features[0].properties.approach_id = "other-way:w1";
|
||||
assert.throws(() => validateTrafficSignalFeatures(mismatchedIdentity), /approach_id does not match source_way_id/);
|
||||
const invalidEnabled = structuredClone(cross);
|
||||
invalidEnabled.features[0].properties.enabled = "maybe";
|
||||
assert.throws(() => validateTrafficSignalFeatures(invalidEnabled), /invalid enabled/);
|
||||
assert.doesNotThrow(() => validateTrafficSignalSourceReferences(cross, [control]));
|
||||
assert.throws(
|
||||
() => validateTrafficSignalSourceReferences(cross, [{ ...control, arms: arms.slice(1) }]),
|
||||
/approach_id .* is not present on OSM control/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateTrafficSignalSourceReferences(cross, []),
|
||||
/control_id .* is not present in the current OSM/,
|
||||
);
|
||||
for (const disabledValue of [false, 0, "0", "false", "no"]) {
|
||||
const disabled = structuredClone(cross);
|
||||
disabled.features[0].properties.enabled = disabledValue;
|
||||
assert.equal(buildTrafficSignalsFromFeatures(disabled).signals.length, 3);
|
||||
}
|
||||
for (const key of ["heading_deg", "phase_group", "stop_lon", "stop_lat", "z_offset_m"]) {
|
||||
const missingNumber = structuredClone(cross);
|
||||
missingNumber.features[0].properties[key] = null;
|
||||
assert.throws(
|
||||
() => validateTrafficSignalFeatures(missingNumber),
|
||||
new RegExp(`missing ${key}`),
|
||||
`${key} must not silently coerce null to zero`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Traffic signal tests passed.");
|
||||
Reference in New Issue
Block a user