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

@@ -144,20 +144,22 @@ OSM way 的端点不一定在原始 XML 中有三个以上相连 wayosm2stree
**教训****跨阶段契约必须随产物保存;兼容旧产物的字符串回退也要被审查**。
### 坑 6部分构建复用了过期的交通信号锚点
### 坑 6部分构建复用了过期的交通信号运行时数据
`scripts/lib/traffic-signals.js` 根据当前 OSM 的 `highway=traffic_signals` 控制节点、
`vehicle_stop_lines.geojson``intersection_surface.geojson` 派生
`osm2streets_web_out/traffic_signals.json`。Blender 将这份文件中的每个 signal id 导出为
三个动态灯节点和一组倒计时节点,`scripts/lib/cesium-preview.js` 再按相同 id 控制它们。
`intermediates` 才能根据当前 OSM 的 `highway=traffic_signals` 控制节点、
`vehicle_stop_lines.geojson``intersection_surface.geojson` 初始化可编辑的
`traffic_signal_assemblies.geojson`。此后该 GeoJSON 是 QGIS 编辑生命周期内的事实源;
`traffic_signals.json` 只是严格校验后派生的运行时数据。Blender 将其中每个稳定的
`signal_uid` 导出为静态设施、三个动态灯节点和一组倒计时节点,
`scripts/lib/cesium-preview.js` 再按相同 id 控制它们。
因此执行 `blender,cesium,preview` 这类部分构建时,必须在 Blender stage 入口重新写入
交通信号文件。若沿用旧文件GLB 可能已经包含新增 T/十字路口,但预览仍只控制旧的
signal id表现为灯不切换、数字叠加或路口整体异常。该刷新由
因此执行 `blender,cesium,preview` 这类部分构建时,必须在 Blender stage 入口从当前
`traffic_signal_assemblies.geojson` 重建运行时 JSON但绝不能重新从 OSM 初始化位置,
否则会覆盖 QGIS 中移动、旋转或禁用设施的编辑。该刷新由
`scripts/build-area.js:buildBlenderScene()` 负责。
**教训****任何由输入 OSM 或归一化图层派生、又被多个后续 stage 共享的中间 JSON
都必须在最早消费它的 stage 重新生成,不能只在完整 `intermediates` 构建时生成。**
**教训****跨阶段运行时 JSON 必须在最早消费它的 stage 从当前权威产物重建;同时要
区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。**
---

View File

@@ -343,13 +343,14 @@ out.vehicleStopLines = crosswalkData.stopLines;
// 原生 lane_markings 停止线不得复制到输出。
```
## 信号锚点的跨阶段消费
## 可编辑信号设施与运行时锚点的跨阶段消费
### 1. Scope / Trigger
路口信号设施需要同时被 Blender 主 GLB 和 Cesium 预览消费时,使用
`<geojsonDir>/traffic_signals.json`。它是附属 intermediates 产物,而不是第十个
osm2streets/QGIS 图层。
`<geojsonDir>/traffic_signal_assemblies.geojson` 是 GeoPackage/QGIS 中的附属可编辑点图层;
`<geojsonDir>/traffic_signals.json` 是从它严格校验并派生的运行时产物。前者不属于九个
`SCENE_LAYERS`,后者不进入 GeoPackage。
### 2. Signatures
@@ -366,15 +367,19 @@ area.outputs.trafficSignals
### 3. Contracts
- `build-area.js:writeTrafficSignals()` 是锚点 JSON 的生产者,调用
`traffic-signals.js:readTrafficSignals()`,输入为 `vehicle_stop_lines.geojson`
`intersection_surface.geojson`
- `intermediates``reimport` 都必须在其 GeoJSON 产物稳定后重写锚点,确保 QGIS
人工修补反导入后Blender 和 preview 仍使用同一事实
- `intermediates` 从 OSM control、停止线和路口面初始化 `traffic_signal_assemblies.geojson`
并将其作为附属点层导入 GeoPackage完整重跑 intermediates 会像道路图层一样覆盖人工编辑。
- `reimport` 必须与九个场景层一起暂存导出附属层,先校验全部信号要素,再替换任何输出
- `build-area.js:writeTrafficSignals()` 只从当前 `traffic_signal_assemblies.geojson` 重建运行时
`traffic_signals.json`。Blender 入口也执行这一步,但不得重新从 OSM 初始化位置
- `blender``preview` 在启动前必须检查该文件存在;前者把静态设施写进 `05_Props`
后者只叠加动态灯珠、倒计时和车辆相位。
- `traffic_signals.json` 不得加入 `SCENE_LAYERS`GeoPackageQGIS 工程;这些层只能
继续包含九个道路场景图层
- `traffic_signal_assemblies.geojson` 必须加入 GeoPackage/QGIS 工程,但不得加入
`SCENE_LAYERS`、合并道路场景或栅格预览;`traffic_signals.json` 仍不得加入 GeoPackage
- `signal_uid` 必须由 control id、source way id 和相邻 arm node id 确定性生成;运行时 `id`
使用该技术 id。`display_id` 可编辑且非空时唯一,修改它不得重命名 GLB 节点。
- Point 几何是灯杆地面点;`stop_lon`/`stop_lat` 独立保存,移动杆件不得移动车辆停止点。
- `enabled=false` 的要素保留在编辑层但不进入运行时 signals。
- `layout.countdownLateralMeters` 等几何字段是 Blender/preview 的共同事实源;横向正值统一
表示相对来车方向的右侧。不得在任一消费方用独立的负号约定替代它。
- `layout.mastHeightMeters``layout.headCenterHeightMeters` 必须相等,表示横杆与灯壳的
@@ -385,9 +390,9 @@ area.outputs.trafficSignals
| 条件 | 结果 |
|---|---|
| `intermediates``reimport` 有合法停止线和路口面 | 写出 `version``signals` 数组,即使数组为空 |
| `intermediates``reimport` 有合法编辑层 | 写出 `version``signals` 数组,即使数组为空 |
| 直接运行 `blender` / `preview` 但锚点不存在 | 在启动外部工具前报 `Traffic signal anchors not found` |
| 单个停止线无法可靠关联路口 | 锚点生成器跳过该项,其他进口照常输出 |
| `signal_uid` 缺失/重复、非空 `display_id` 重复、字段或 Point 无效 | 重导入在替换任何输出前失败 |
| 用户仅修改 QGIS 后运行 `reimport` | 重新生成锚点,不沿用旧坐标 |
### 5. Good/Base/Bad Cases

View File

@@ -0,0 +1,5 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Check stage ownership, diagnostics, and traffic signal contract compliance"}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"Check atomic reimport behavior and external-tool handling"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Check full editable-layer to runtime JSON to Blender/Cesium data flow"}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Check intended and unintended scene/GLB structural differences"}
{"file":".trellis/spec/blender/testing.md","reason":"Check appropriate pure and Blender validation coverage"}

View File

@@ -0,0 +1,100 @@
# Design: QGIS Traffic Signal Overrides
## Architecture
Introduce a separate auxiliary-edit-layer registry rather than adding traffic signals to `SCENE_LAYERS`. The initial registry contains one layer:
```text
traffic_signal_assemblies.geojson
geometry: Point (pole ground position, EPSG:4326)
properties: stable identity, source identity, heading, phase, reach, stop point, enabled, z offset
```
The existing runtime file remains:
```text
traffic_signals.json
version/layout/signals[] with full pose.* data
```
The editable GeoJSON is the placement source; the runtime JSON is a derived consumer artifact.
## Data Flow
```text
OSM controls + topology + stop lines + intersections
|
intermediates only
v
traffic_signal_assemblies.geojson
|
import into GeoPackage
|
edit in QGIS
|
reimport
v
traffic_signal_assemblies.geojson
|
validate + derive pose
v
traffic_signals.json
/ \
Blender preview/Cesium
```
`blender` reruns the final validation/derivation arrow from the editable GeoJSON so derived JSON cannot be stale, but it never reruns the OSM initialization arrow.
## Editable Feature Contract
Recommended properties:
| Property | Type | Ownership |
|---|---|---|
| `signal_uid` | string | generated, immutable technical identity |
| `display_id` | string | user-editable unique label/number |
| `control_id` | string | generated OSM control id |
| `approach_id` | string | generated physical approach identity |
| `source_way_id` | string | generated matching/diagnostic field |
| `heading_deg` | number | user-editable assembly facing direction |
| `phase_group` | integer 0/1 | user-editable current two-phase group |
| `mast_reach_m` | positive number | user-editable arm reach |
| `stop_lon`, `stop_lat` | finite numbers | generated vehicle stop point, preserved when pole moves |
| `enabled` | boolean/integer | user-editable suppression flag |
| `z_offset_m` | finite number | user-editable vertical adjustment |
Point geometry is the pole longitude/latitude. The runtime `id` should be derived from `signal_uid`, not display numbering, so changing `display_id` does not rename GLB nodes or break preview control.
## Stable Identity
Extend parsed OSM arm data to retain enough deterministic source identity (control node, way, adjacent arm direction/node). Generate a technical key from those source values. Do not use sorted array index or rounded heading as the primary key.
If topology changes on a future `intermediates` run, the rebuilt GeoPackage may produce new identities. This is consistent with current road-edit lifecycle and is explicitly out of scope for MVP migration. Validation still reports duplicate identities and malformed source fields.
## QGIS Integration
- Add an auxiliary layer definition separate from the nine render layers.
- Import it into the same GeoPackage after render layers.
- Include it in the generated project but exclude it from the merged scene and 2D raster preview unless deliberately enabled for editing visibility.
- Use a point marker plus rotated direction indicator driven by `heading_deg` and label by `display_id`, falling back to `signal_uid`.
- Configure read-only/editor widgets where practical: technical/source ids read-only; phase group constrained to 0/1; numeric fields constrained to valid ranges; enabled as checkbox.
## Reimport and Atomicity
Extend the reimport layer manifest to include auxiliary editable layers while keeping render-scene merge derived only from `SCENE_LAYERS`. Export every layer into staging, parse and validate all editable features, then replace output files. Runtime JSON is written only after the staged auxiliary layer passes validation.
## Compatibility
- The next `intermediates` run bootstraps existing areas; no old JSON migration is required.
- Main `.blend`/GLB geometry changes intentionally when a QGIS edit changes a signal.
- Dynamic and countdown GLBs continue using runtime signal ids, now stable across ordinary reimport edits.
- Current two-phase simulation remains unchanged.
## Risks and Controls
- **OSM way splitting changes source ids:** accepted across a full intermediates rebuild; ordinary reimport is stable.
- **QGIS boolean/string coercion:** normalize known GDAL representations before strict validation and test the round-trip output.
- **Accidental source-field editing:** mark technical fields read-only in QGIS and validate identity format during reimport.
- **Partial overwrite on invalid auxiliary data:** retain the existing staging-before-replace discipline.
- **Old spec conflict:** update pipeline specs that currently forbid traffic-signal anchors in GeoPackage, clarifying the distinction between editable assembly points and derived runtime anchors.

View File

@@ -0,0 +1,6 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Traffic signal stage ownership, reimport lifecycle, manifests, and current anchor contract"}
{"file":".trellis/spec/pipeline/layer-registry.md","reason":"Keep the auxiliary editable layer separate from the nine render layers and preserve their order"}
{"file":".trellis/spec/pipeline/external-tools.md","reason":"GeoPackage import/export and staging-before-replace requirements"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"OSM to GeoJSON/GPKG to Blender/Cesium contract review"}
{"file":".trellis/spec/blender/asset-generation.md","reason":"Signal pose and dynamic asset generation constraints"}
{"file":".trellis/spec/preview/vehicle-routes.md","reason":"Vehicle stop coordinates and runtime signal data coupling"}

View File

@@ -0,0 +1,53 @@
# Implementation Plan: QGIS Traffic Signal Overrides
## 1. Contracts and Pure Logic
- [x] Add an auxiliary editable-layer definition without modifying `SCENE_LAYERS` ordering.
- [x] Extend OSM arm parsing with deterministic approach identity inputs.
- [x] Split traffic-signal logic into automatic editable-feature generation, feature validation/normalization, and runtime pose derivation.
- [x] Use stable technical ids for runtime signal ids; keep `display_id` as editable metadata.
- [x] Add pure Node tests for T/cross counts, stable ids, movement/heading reconstruction, disabled features, duplicate ids, and invalid values.
## 2. Intermediates and QGIS
- [x] Write `traffic_signal_assemblies.geojson` after stop-line/intersection outputs are stable.
- [x] Import the auxiliary point layer into the GeoPackage after the nine render layers.
- [x] Extend generated QGIS project code with point/direction styling, labels, and field widgets/constraints.
- [x] Confirm the auxiliary layer is excluded from merged road scene ordering and raster preview behavior.
## 3. Reimport and Stage Ownership
- [x] Extend `reimport-gpkg.js` to discover/export render and auxiliary layers through staging.
- [x] Validate the staged editable layer before replacing any output artifact.
- [x] Rebuild runtime `traffic_signals.json` from editable GeoJSON after `intermediates`, `reimport`, and at Blender entry.
- [x] Remove Blender-entry OSM placement regeneration so QGIS edits remain authoritative.
- [x] Extend stage manifests and diagnostics with auxiliary input/output records and feature counts.
## 4. Cross-Layer Consumers
- [x] Preserve `display_id` and stable runtime ids through Blender and Cesium metadata where useful.
- [x] Verify static signal objects, dynamic lenses, countdown nodes, and vehicle stop behavior all consume the same enabled runtime records.
- [x] Update pipeline specifications to replace the old prohibition with the editable-layer/derived-runtime distinction.
## 5. Validation
- [x] Run Node syntax checks and focused unit tests.
- [x] Run existing preview-assets, preflight, budget, and relevant pipeline tests.
- [x] Run `intermediates` and inspect the GeoPackage/QGIS project feature schema and styling.
- [ ] Make a controlled QGIS edit to one signal (display id, point, heading), run `reimport,blender,cesium,preview`, and verify only the intended assembly changes. (`reimport` and Blender passed; Cesium/preview refresh was not repeated.)
- [x] Confirm an invalid/duplicate edit fails before overwriting valid outputs.
- [ ] Inspect Blender/Cesium structural digests and Safari preview for T and cross junctions. (Blocked this run by Blender 4.5.12 Metal startup SIGSEGV before project Python.)
## Risky Files / Rollback Points
- `scripts/lib/traffic-signals.js`: identity and pose contract; land pure tests before pipeline integration.
- `scripts/build-osm2streets-qgis.js`: GeoPackage recreation and generated QGIS Python; verify auxiliary import independently before styling.
- `scripts/reimport-gpkg.js`: atomic overwrite boundary; preserve staging semantics.
- `scripts/build-area.js`: stage ownership; ensure Blender derives from editable GeoJSON rather than overwriting it.
- `blender/osmassets/traffic_signals.py` and preview runtime should require minimal or no geometry changes; unexpected edits here indicate contract leakage.
## Review Gate Before Start
- [ ] User approves the final planning summary.
- [ ] `prd.md`, `design.md`, and `implement.md` agree on full editable layer ownership and out-of-scope intermediates persistence.
- [ ] No unresolved product decision remains.

View File

@@ -0,0 +1,14 @@
# Debug Notes
## 2026-08-07 countdown node-name regression
Stable `signal_uid` values are intentionally descriptive and can exceed Blender's
63-byte object-name limit. Using them directly in dynamic lens/countdown node
names caused Blender to truncate names while Cesium looked up the untruncated
strings. The countdown GLBs then exposed all digits without the runtime being
able to hide the inactive values, appearing as overlapping/blurred numbers.
Runtime signal records now carry a deterministic short `nodeKey` (`ts_` plus
the first 16 hex characters of SHA-256 of `signal_uid`). Blender uses it for
dynamic object names and Cesium uses the same key for lookups. Preview keeps a
fallback to `signal.id` for older metadata files.

View File

@@ -0,0 +1,67 @@
# QGIS Traffic Signal Overrides
## Goal
Make every automatically generated vehicle traffic signal independently identifiable and editable in QGIS. A user must be able to assign a display number, move a pole, rotate its assembly, adjust supported placement attributes, run `reimport`, and have Blender and Cesium consume that edited result without OSM regeneration overwriting it.
## Background
- Current traffic signals are derived from OSM `highway=traffic_signals` controls, road topology, `vehicle_stop_lines.geojson`, and `intersection_surface.geojson` by `scripts/lib/traffic-signals.js`.
- Current sequential ids such as `signal-1` depend on generation order and are not suitable as persistent edit identities.
- Current `<geojsonDir>/traffic_signals.json` contains fully derived `pose.*` data but is deliberately excluded from the GeoPackage and QGIS project.
- Existing road editing establishes the desired lifecycle: `intermediates` initializes a GeoPackage, the user edits it in QGIS, and `reimport` exports the edited data back to GeoJSON. Running `intermediates` again may discard manual edits; that behavior remains explicit and unchanged.
## Requirements
### R1. Editable auxiliary layer
- `intermediates` must create a point FeatureCollection containing one feature per physical signal assembly and import it into the area GeoPackage.
- The generated QGIS project must expose the layer with a visible directional symbol and a label suitable for identifying individual signals.
- The auxiliary layer must not join `SCENE_LAYERS` or the merged road scene because it is an editing/control artifact, not a road render layer.
### R2. Stable identity and numbering
- Every generated feature must contain an immutable technical `signal_uid` derived deterministically from its OSM control and physical approach identity, rather than array order.
- Every feature must contain an editable `display_id` intended for user-facing numbering.
- Build/reimport validation must reject duplicate or missing `signal_uid` values and duplicate non-empty `display_id` values with an actionable error.
### R3. Editable placement contract
- Point geometry represents the pole ground position.
- Editable attributes must include at least `display_id`, `heading_deg`, `phase_group`, `mast_reach_m`, `enabled`, and `z_offset_m`.
- Source/control attributes required for matching and diagnostics must be preserved, including `control_id` and approach identity.
- Vehicle stop coordinates remain independent attributes; moving the pole must not silently move the vehicle stop point.
- After reimport, the pipeline must deterministically rebuild `pose.pole`, `pose.arm`, `pose.head`, `pose.lenses`, and `pose.countdown` from the edited point and attributes.
### R4. Stage ownership
- `intermediates` initializes the editable signal layer from current OSM/topology and derives the runtime `traffic_signals.json` from it.
- `reimport` must stage, validate, and export the editable signal layer along with the existing road layers, then rebuild the runtime JSON.
- `blender` must rebuild runtime `traffic_signals.json` from the current editable signal GeoJSON. It must not recompute signal placement directly from OSM and erase QGIS edits.
- `cesium` and `preview` continue consuming artifacts derived from the same runtime JSON and retain matching signal node ids.
### R5. Diagnostics and compatibility
- Invalid geometry, invalid numeric fields, duplicate identities, unsupported phase groups, and unmatched source references must fail before replacing valid output artifacts.
- `enabled=false` suppresses a signal without requiring feature deletion, so automatic regeneration cannot accidentally resurrect an intentionally disabled assembly within the same edit lifecycle.
- Existing areas without an editable signal layer must receive one on their next `intermediates` run. No migration of previously hand-edited traffic signal JSON is required.
## Acceptance Criteria
- [ ] A clean `intermediates` run creates the editable traffic-signal GeoJSON, a GeoPackage layer with the same feature count, and a QGIS project layer with labels and directional symbols.
- [ ] T junctions produce three editable features and cross junctions produce four, each with a unique deterministic `signal_uid`.
- [ ] Moving one point in QGIS and changing its `display_id` and `heading_deg`, followed by `reimport,blender,cesium,preview`, changes only that signal assembly's placement/identity-facing metadata while preserving its vehicle stop point.
- [ ] Re-running `blender` after reimport does not overwrite the QGIS-edited pole position or heading from OSM.
- [ ] Setting one feature to disabled removes its static and dynamic signal assets while leaving the other signals intact.
- [ ] Duplicate `signal_uid` or non-empty `display_id`, invalid geometry, and invalid placement fields abort reimport without partially replacing GeoJSON outputs.
- [ ] Blender/Cesium node counts and ids match the enabled features in the final runtime JSON; lights and countdowns continue switching correctly.
- [ ] Existing road GeoPackage import/reimport behavior and merged scene layer order remain unchanged.
- [ ] Unit/integration tests cover stable ids, editable-feature validation, override-to-pose reconstruction, auxiliary GeoPackage round-trip, and stage ownership.
## Out Of Scope
- Preserving QGIS edits across a subsequent full `intermediates` rebuild; as with road edits, users must preserve or reapply edits before regenerating the GeoPackage.
- A complete traffic-controller timing editor or arbitrary multi-phase signal program.
- Independent editing of each lens or countdown glyph position; those remain derived from the assembly point, heading, and shared layout.
- Automatically assigning a stable identity to a brand-new signal feature drawn manually in QGIS.

View File

@@ -0,0 +1,26 @@
{
"id": "qgis-traffic-signal-overrides",
"name": "qgis-traffic-signal-overrides",
"title": "QGIS traffic signal overrides",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-07",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M 50 96 L 24 50 L 76 50 Z"
fill="#e12d37" stroke="#7d0f19" stroke-width="4" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -129,8 +129,9 @@ def assemble_dynamic(signal_data, projector, collection, materials):
active_lens_depth = min(0.025, layout["lensDepthMeters"])
active_lens_radius = layout["lensRadiusMeters"] * 0.88
active_lens_offset = (layout["lensDepthMeters"] + active_lens_depth) / 2 + 0.003
node_key = signal.get("nodeKey") or signal["id"]
for state in ("red", "yellow", "green"):
batch = MeshBatch("TrafficSignalDynamic_%s_%s" % (signal["id"], state), collection, materials[state])
batch = MeshBatch("TrafficSignalDynamic_%s_%s" % (node_key, state), collection, materials[state])
for index in (0, 1, 2):
point = pose["lenses"][index]
if point["state"] == state:
@@ -149,7 +150,7 @@ def assemble_dynamic(signal_data, projector, collection, materials):
phase_group = int(signal.get("phaseGroup") or 0) % 2
for value, mesh in countdown_meshes[phase_group].items():
objects.append(_countdown_instance(
"TrafficSignalDynamic_%s_countdown_%s" % (signal["id"], value),
"TrafficSignalDynamic_%s_countdown_%s" % (node_key, value),
mesh, collection, text_x, text_y, board_z, lateral, face))
return objects

View File

@@ -16,6 +16,7 @@
"test:preview-assets": "node scripts/test-preview-assets.js",
"test:compress-glb": "node scripts/test-compress-glb.js",
"test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js",
"test:traffic-signals": "node scripts/test-traffic-signals.js",
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
},
"dependencies": {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.");