feat(qgis): add editable traffic signal assemblies
This commit is contained in:
@@ -144,20 +144,22 @@ OSM way 的端点不一定在原始 XML 中有三个以上相连 way;osm2stree
|
||||
|
||||
**教训**:**跨阶段契约必须随产物保存;兼容旧产物的字符串回退也要被审查**。
|
||||
|
||||
### 坑 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 从当前权威产物重建;同时要
|
||||
区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`、GeoPackage 或 QGIS 工程;这些层只能
|
||||
继续包含九个道路场景图层。
|
||||
- `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
|
||||
|
||||
@@ -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"}
|
||||
100
.trellis/tasks/08-07-qgis-traffic-signal-overrides/design.md
Normal file
100
.trellis/tasks/08-07-qgis-traffic-signal-overrides/design.md
Normal 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.
|
||||
|
||||
@@ -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"}
|
||||
@@ -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.
|
||||
14
.trellis/tasks/08-07-qgis-traffic-signal-overrides/notes.md
Normal file
14
.trellis/tasks/08-07-qgis-traffic-signal-overrides/notes.md
Normal 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.
|
||||
67
.trellis/tasks/08-07-qgis-traffic-signal-overrides/prd.md
Normal file
67
.trellis/tasks/08-07-qgis-traffic-signal-overrides/prd.md
Normal 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.
|
||||
|
||||
26
.trellis/tasks/08-07-qgis-traffic-signal-overrides/task.json
Normal file
26
.trellis/tasks/08-07-qgis-traffic-signal-overrides/task.json
Normal 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": {}
|
||||
}
|
||||
4
assets/qgis/traffic-signal-direction.svg
Normal file
4
assets/qgis/traffic-signal-direction.svg
Normal 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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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