feat(qgis): add editable traffic signal assemblies

This commit is contained in:
2026-08-07 12:48:38 +08:00
parent 72fa04ddeb
commit e153a1c57d
24 changed files with 766 additions and 243 deletions

View File

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