diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md index d9bec4e..b080f4e 100644 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -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 从当前权威产物重建;同时要 +区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。** --- diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 06ba84e..25c2c6e 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -343,13 +343,14 @@ out.vehicleStopLines = crosswalkData.stopLines; // 原生 lane_markings 停止线不得复制到输出。 ``` -## 信号锚点的跨阶段消费 +## 可编辑信号设施与运行时锚点的跨阶段消费 ### 1. Scope / Trigger 路口信号设施需要同时被 Blender 主 GLB 和 Cesium 预览消费时,使用 -`/traffic_signals.json`。它是附属 intermediates 产物,而不是第十个 -osm2streets/QGIS 图层。 +`/traffic_signal_assemblies.geojson` 是 GeoPackage/QGIS 中的附属可编辑点图层; +`/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 diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/check.jsonl b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/check.jsonl new file mode 100644 index 0000000..113b732 --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/check.jsonl @@ -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"} diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/design.md b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/design.md new file mode 100644 index 0000000..d7114ab --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/design.md @@ -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. + diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/implement.jsonl b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/implement.jsonl new file mode 100644 index 0000000..f965b3b --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/implement.jsonl @@ -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"} diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/implement.md b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/implement.md new file mode 100644 index 0000000..98768ad --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/implement.md @@ -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. diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/notes.md b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/notes.md new file mode 100644 index 0000000..dc97d04 --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/notes.md @@ -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. diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/prd.md b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/prd.md new file mode 100644 index 0000000..6488b26 --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/prd.md @@ -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 `/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. + diff --git a/.trellis/tasks/08-07-qgis-traffic-signal-overrides/task.json b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/task.json new file mode 100644 index 0000000..afe91b0 --- /dev/null +++ b/.trellis/tasks/08-07-qgis-traffic-signal-overrides/task.json @@ -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": {} +} \ No newline at end of file diff --git a/assets/qgis/traffic-signal-direction.svg b/assets/qgis/traffic-signal-direction.svg new file mode 100644 index 0000000..1aef48a --- /dev/null +++ b/assets/qgis/traffic-signal-direction.svg @@ -0,0 +1,4 @@ + + + diff --git a/blender/osmassets/traffic_signals.py b/blender/osmassets/traffic_signals.py index c417312..1417207 100644 --- a/blender/osmassets/traffic_signals.py +++ b/blender/osmassets/traffic_signals.py @@ -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 diff --git a/package.json b/package.json index 5d6783f..7190021 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/build-area.js b/scripts/build-area.js index 8156306..a930609 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -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}`); } diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index 47ce5ee..0208d79 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -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}") diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index eda1ce9..0191615 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -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")), diff --git a/scripts/lib/area-diagnostics.js b/scripts/lib/area-diagnostics.js index b3a8fd5..94f29b4 100644 --- a/scripts/lib/area-diagnostics.js +++ b/scripts/lib/area-diagnostics.js @@ -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, diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 7fabdb5..75e536b 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -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)) { diff --git a/scripts/lib/osm.js b/scripts/lib/osm.js index c192df3..03e8f75 100644 --- a/scripts/lib/osm.js +++ b/scripts/lib/osm.js @@ -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), + }); } } } diff --git a/scripts/lib/scene-layers.js b/scripts/lib/scene-layers.js index faceb3a..2b2c4d8 100644 --- a/scripts/lib/scene-layers.js +++ b/scripts/lib/scene-layers.js @@ -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, diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index 1101fe6..859cd01 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -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, +}; diff --git a/scripts/reimport-gpkg.js b/scripts/reimport-gpkg.js index 85c39ab..f0a25b8 100755 --- a/scripts/reimport-gpkg.js +++ b/scripts/reimport-gpkg.js @@ -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; diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index a7f62e8..22bd9e8 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -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 } }), diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 90da8f2..f99a321 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -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/); diff --git a/scripts/test-traffic-signals.js b/scripts/test-traffic-signals.js new file mode 100644 index 0000000..bc95a06 --- /dev/null +++ b/scripts/test-traffic-signals.js @@ -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.");