feat: add OSM turn lane arrows

This commit is contained in:
2026-08-05 10:17:34 +08:00
parent 9970e3eeef
commit 9a23f74f0f
34 changed files with 1540 additions and 33 deletions

View File

@@ -51,6 +51,7 @@ cp config/examples/template.json config/areas/my-area.json
| `blenderApp` | | `/Applications/Blender.app` | |
| `stages` | | 见下 | 各阶段默认开关 |
| `qgis` | | 见下 | QGIS/osm2streets 旋钮 |
| `turnLaneArrows` | | 见下 | 从 OSM `turn:lanes:*` 生成自定义车道箭头的发布开关 |
| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 |
| `blender` | | 见下 | Blender 侧选项 |
| `compress` | | 见下 | 显式 `compress` 阶段的 GLB 压缩选项 |
@@ -97,6 +98,15 @@ cp config/examples/template.json config/areas/my-area.json
> 这四个 arrow/corner 旋钮的默认值都是调出来的,**改之前先看 README 里记的理由**。
> 尤其 `arrowOutlineSimplifyMeters`——调大会开始削箭头头部。
### `turnLaneArrows`
| 字段 | 默认 | 说明 |
|---|---|---|
| `enabled` | `false` | 仅在样张经用户确认后启用。启用时从 `turn:lanes:forward` / `turn:lanes:backward` 追加经过测试的自定义箭头;未测试素材永不参与映射。 |
该开关经 `normalizeAreaConfig()``writeDerivedConfig()` 传入 intermediates 阶段。必须用
`??` 保留 `false`;不要将它改为按隐式标签或环境变量自动启用。
### `osm2streets`
原样透传给 `JsStreetNetwork` 构造函数(`build-osm2streets-qgis.js:77`)。默认:

View File

@@ -83,6 +83,18 @@ cesium-preview.js 浏览器
## 本项目真实踩过的坑
### 坑 0用原始 OSM 节点度数代替归一化路网拓扑
OSM way 的端点不一定在原始 XML 中有三个以上相连 wayosm2streets 可能把相邻 way
合并、切分或通过 `network.intersections[*].osm_ids` 表达路口。任何需要判断道路是否
进入路口的中间层逻辑,都必须优先使用已经生成的 normalized `network.json` 事实源,
原始节点度数只能作为没有 normalized network 的纯单元测试回退。
### 坑 0.1:普通 JSON 误走 FeatureCollection 写入器
`writeJson()` 的隐式契约是传入带 `features` 数组的图层集合;诊断 manifest、计数摘要等
普通对象必须用显式 `JSON.stringify` 写入,不能为了复用日志代码把它们塞进图层写入器。
### 坑 1同一份事实存了四份
九个图层的顺序曾同时存在于 z_index 表、样式 JSON、QGIS 工程、README。

View File

@@ -189,6 +189,158 @@ const osm = parseOsm(fs.readFileSync(area.input, "utf8"));
const preflight = analyzeOsmPreflight(osm);
```
## OSM 转向车道箭头
### 1. 范围与触发条件
区域配置设为 `turnLaneArrows.enabled: true` 时,`intermediates` 阶段会将已支持的
OSM `turn:lanes:forward` / `turn:lanes:backward` 标线追加到既有的
`lane_arrows_webscale` 图层。这只是既有图层的新增数据来源,不能新增 QGIS、Blender
或 Cesium 图层。
### 2. 调用形式
```json
{
"turnLaneArrows": { "enabled": true }
}
```
该字段由 `normalizeAreaConfig()` 归一化、`writeDerivedConfig()` 写入派生配置,
并由 `build-osm2streets-qgis.js` 消费。
### 3. 契约
- 已支持且已测试的转向为:`through``left``right``through;left`
`through;right``through;left;right`
- `assets/lane-icons/manifest.json` 是上游 CC0 来源、原始 SVG、镜像规则、箭杆轴线、
支持状态和测试状态的唯一事实源。未同时标记为 `supported``tested` 的素材不得写入
生产 GeoJSON。
- 自定义要素必须带有 `source="osm_turn_lanes"``osm_way_id``direction`
`lane_index``maneuver``source_asset``arrow_part` 与确定性的
`custom_arrow_id`
- `turn_lane_arrow_diagnostics.json` 记录 `generated` 和结构化跳过原因;它是普通 JSON
不是 FeatureCollection。
- 归一化后的 `network.intersections[*].osm_ids` 用来判断有效路口端点。只有单元测试中
没有 network 时,才可退回原始 OSM 节点度数。
- 可直接关联时,`Driving` 面通过 `osm_way_ids` 匹配,中心线取其相对两边的中点;闭合
四边形车道面也必须有效。JOSM 拆分产生的临时 ID 未被渲染面保留时,只能匹配距离近且
行驶方向一致的车道中心线,并按实际横向位置排序,不得单独依赖渲染器的 `index`
### 4. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 功能关闭 | 不生成自定义要素;诊断原因为 `disabled` |
| 缺少车道数或无法判定路口端点 | 带原因跳过,不能猜测位置 |
| 转向未支持或未测试 | 以 `unsupported_or_untested_maneuver` 跳过 |
| SVG 缺少受支持路径命令或 manifest 锚点 | 写入 GeoJSON 前抛错 |
| 合法且已支持的 OSM 车道 | 每个 SVG 填充或扩展描边部件追加一个 Polygon |
### 5. 正常、基础与错误示例
- 正常:`through;right` 生成多个合法 Polygon 部件,但共享同一 OSM 溯源元组,直行杆
轴线与车道中心对齐。
- 基础:`enabled: false` 保持 osm2streets 原有箭头输出不变。
- 错误:将组合箭头写为一个 `MultiPolygon`。既有 QGIS 归一化器只接受逐个 Polygon。
### 6. 必需测试
- `npm run test:turn-lane-arrows`:覆盖转向归一化、正反向放置、确定性 ID、箭杆锚点、
关闭行为及未测试素材排除。
- 目标区域 `intermediates` 构建:确认 `lane_arrows_webscale.geojson` 含自定义 `source`
要素,且诊断可读。
- 用户确认样张后,运行完整目标区域构建和
`npm run check:area -- --config <area>`
### 7. 错误与正确写法
错误:
```js
if ((roadCounts.get(endpoint.id) || 0) < 3) return null;
writeJson(diagnosticsPath, diagnostics);
```
正确:
```js
const networkSaysIntersection = networkIntersectionNodes.has(endpoint.id);
if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return null;
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
```
## 斑马线与停止线来源
### 1. 范围与触发条件
`intermediates` 阶段从带标记的 OSM `highway=crossing` 节点生成路口标线,并独占
`crosswalks``vehicle_stop_lines` 两个场景图层的数据来源。
### 2. 调用形式
```js
const crosswalkData = buildCrosswalks(osm, lanePolygons.features);
// split.vehicleStopLines === crosswalkData.stopLines
// split.crosswalks === crosswalkData.stripes
```
### 3. 契约
- `buildCrosswalks()` 是输出 `vehicle_stop_lines` 的唯一生产者。不得将 osm2streets
`lane_markings` 中类型为 `vehicle stop line` 的要素追加回来,否则会产生位置不同的
重复标线。
- 每个输出的过街进口固定生成 6 条 `crosswalk stripe`,以及恰好 1 条带有
`source="crosswalk"``crossing_node_id``vehicle stop line`
- 斑马线参考点先沿进口方向放在过街节点簇中心外 7 米处;再以相邻归一化 `Driving`
修正横向中心和方向:取局部车道中心锚点的平均值,并使用方向一致的车道切线。只有没有
匹配的渲染车道时,才回退到原始 OSM way 几何。
- 停止线从同一校正后的斑马线坐标系,以外侧固定 1.2 米偏移推导。osm2streets 的道路
横断面与原始 OSM 几何不同,斑马线与停止线也必须同步移动。
### 4. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 有匹配渲染车道的标记过街 | 中心与方向跟随实际渲染道路横断面 |
| 没有匹配渲染车道的标记过街 | 安全回退到原始 OSM 方向向量 |
| 多个源节点投影到同一进口中心 | 写入斑马线和停止线前去重 |
| 存在 osm2streets 原生停止线 | 忽略,不写入输出图层 |
### 5. 正常、基础与错误示例
- 正常:两个四向路口产生 48 条斑马线条带和 8 条停止线,全部为
`source="crosswalk"`
- 基础:归一化 `Driving` 面之外的过街仍可按原始 way 方向渲染。
- 错误:因为原生 `vehicle stop line` 与人工线相距数米就保留它;这会重新引入第二个
生产者和视觉重复。
### 6. 必需测试
- 运行目标区域 `intermediates` 构建。
- 断言 `vehicle_stop_lines.geojson` 每个要素的 `crossing_node_id` 均不重复,且所有要素
都有 `source="crosswalk"`
- 检查四向路口的 QGIS 预览:条带必须横向居中于渲染道路宽度,每条停止线必须位于其
对应斑马线之后。
### 7. 错误与正确写法
错误:
```js
if (feature.properties.type === "vehicle stop line") {
out.vehicleStopLines.features.push(feature);
}
```
正确:
```js
const crosswalkData = buildCrosswalks(osm, lanePolygons.features);
out.vehicleStopLines = crosswalkData.stopLines;
// 原生 lane_markings 停止线不得复制到输出。
```
## 区域诊断命令
### 1. Scope / Trigger

View File

@@ -0,0 +1,4 @@
{"file":".trellis/spec/pipeline/index.md","reason":"Defines the expected intermediates artifacts and dependency boundaries."}
{"file":".trellis/spec/pipeline/layer-registry.md","reason":"Requires preserving existing lane-arrow layer collection and order across QGIS and Blender."}
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Defines quality-gate and target-area validation expectations."}
{"file":".trellis/tasks/08-04-add-osm-turn-lane-arrows/research/lane-icons.md","reason":"Defines the source provenance and assertion that untested assets cannot be mapped."}

View File

@@ -0,0 +1,74 @@
# Design: OSM turn-lane arrows
## Boundaries
- `scripts/build-osm2streets-qgis.js` remains the intermediates owner. It will
append generated custom features to the existing in-memory `laneArrows`
collection before it is written as `lane_arrows_webscale.geojson`.
- No render layer, QGIS symbol, Blender material, or Cesium export contract
changes. Existing `lane_arrows_webscale` styling and catalog ownership apply
to generated features automatically.
- Vendored upstream artwork lives under a dedicated repository-local asset
directory with its CC0 license and a machine-readable manifest. The manifest
is the source of truth for upstream path, source revision, local path,
support status, and test status.
## Asset Contract
- Import every arrow SVG from `openstreetmap/lane-icons/docs/` that represents
an arrow. Preserve the source bytes and retain `LICENSE.md` with the assets.
- Mark only `through`, `left`, `right`, `through;left`, and `through;right` as
`supported` and `tested` in the manifest. All remaining imported arrows are
`unsupported` and `untested`; no production parser may select them.
- The upstream repository has left-side examples only. Derive `right` and
`through;right` through a deterministic horizontal mirror of the matching
left-side geometry, recording `derived_from` in the manifest.
## Data Flow
1. Parse OSM ways once using the existing OSM parser.
2. Read `turn:lanes:forward` / `turn:lanes:backward`, split lane declarations
by `|`, and map only the five supported normalized maneuvers to manifest
entries.
3. For each valid lane near a determinable intersection endpoint, calculate a
stable lane center and road-local axis; transform the selected local arrow
polygon into WGS84 coordinates.
4. Add a GeoJSON feature with existing render-layer behavior and explicit
provenance: custom source, OSM way id, direction, lane index, maneuver,
source asset, and deterministic feature id/order.
5. Record a structured skipped item when tags, lane geometry, or endpoint
classification are insufficient. Do not invent a fallback location.
6. Existing normalization, scene merge, GeoPackage import, Blender assembly,
and Cesium export consume the expanded collection unchanged.
## Geometry and Compatibility
- Parse only the vendored SVG subset used by supported assets at build time:
`line` elements and `path` commands are converted into local meter-scale
polygons, with cubic curves sampled and SVG strokes expanded into outlines.
The source SVG is the sole geometry authority; no hand-drawn replacement
template is permitted. This stays dependency-free because the supported
upstream syntax is intentionally small and covered by Node tests.
- Use the upstream Standard Arrow shape as the geometry basis, but let the
existing QGIS fill and outline preserve the project's visual language.
- Preserve the existing osm2streets-generated arrow features. Custom features
are additive and have provenance that distinguishes them from upstream
osm2streets output.
- Deterministic ordering is by way id, travel direction, and lane index.
- Each asset manifest entry defines the source SVG's shaft-axis `anchor_x`.
Placement aligns that axis, rather than the SVG viewBox center, with the
lane centerline. Mirrored right-side variants mirror around the same axis.
- Upstream SVG units are converted at `0.10 m` per unit. This calibrates their
local extent against the existing approximately 1.4 m osm2streets arrows;
do not use SVG display-pixel scale as road-marking scale.
## Risks and Rollback
- OSM lane counts, one-way semantics, or endpoint topology can be incomplete.
These cases remain skipped with diagnostics rather than creating misleading
markings.
- Asset import is self-contained and CC0. Rollback removes the custom feature
generation and local asset directory; the existing arrow layer remains
unchanged.
- User approval of the two initial visual samples is required before the
feature is enabled in production output.

View File

@@ -0,0 +1,4 @@
{"file":".trellis/spec/pipeline/index.md","reason":"Defines the intermediates data flow, CommonJS conventions, and existing layer ownership."}
{"file":".trellis/spec/pipeline/layer-registry.md","reason":"The feature adds a source to an existing layer and must preserve the cross-language layer contract."}
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Defines stage ownership and target-area validation commands."}
{"file":".trellis/tasks/08-04-add-osm-turn-lane-arrows/research/lane-icons.md","reason":"Records the selected upstream SVG source, CC0 provenance, and supported-versus-untested asset policy."}

View File

@@ -0,0 +1,39 @@
# Implementation Plan: OSM turn-lane arrows
## Steps
1. Add the complete lane-icons arrow asset set, upstream revision metadata,
CC0 license text, and a manifest with the five supported/tested entries and
explicit untested status for all other entries.
2. Add a small CommonJS helper for asset-manifest loading, source-SVG path and
stroke conversion, maneuver parsing, road-local placement, and diagnostic
skip records. Keep it dependency-free and separately testable.
3. Wire the helper into the intermediates build so custom features are merged
into `laneArrows` before the existing GeoJSON write and normalization path.
4. Add Node tests for the five supported maneuvers, forward/backward endpoint
choice, lane index placement, deterministic order/id, and every required
skip reason. Assert that untested manifest entries cannot be selected.
5. Generate `right` and `through;right` inspection samples from the exact
local templates and request user confirmation. Do not enable custom output
until this gate is met.
6. Run the focused tests, syntax checks, the Nantaizi intermediates build, the
full Nantaizi chain, and `check:area`. Compare against a turn-tag-removed
control to prove an observable GeoJSON difference.
## Validation
- `node --check` for changed CommonJS scripts.
- New pure Node test script(s), invoked directly or through a package script.
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages intermediates`
- Full target-area pipeline after visual approval.
- `npm run check:area -- --config config/areas/nantaizi-lake-innovation-valley.json`
## Review Gates
- No generated custom feature may use an asset whose manifest status is not
both supported and tested.
- Samples must show the same fill/outline treatment as the existing lane-arrow
layer.
- Keep the existing layer registry unchanged; this is a new source for an
existing layer, not a new layer.
- Do not commit without the user's explicit confirmation.

View File

@@ -0,0 +1,63 @@
# Add OSM turn lane arrows
## Goal
让 OSM `turn:lanes:forward` / `turn:lanes:backward` 在现有 QGIS -> Blender -> Cesium
道路链中生成可辨识的 lane arrow同时保持与 osm2streets 既有道路标线相同的视觉语言。
## Confirmed Facts
- 当前 `osm2streets-js-node@0.1.4` 生成 lane arrow但纯内存移除 way `98607123`
`98607128``98607147``turn:lanes:*`593 个原始箭头的 JSON 内容逐字一致;
因此该依赖不消费这些 OSM 标签。
- 当前南台子完整链已经通过:中间层落盘 580 个归一化箭头Blender 装配 397 个,
Cesium / preview / compress manifest 均 fresh`check:area` PASS。
- 现有 `lane_arrows_webscale` 已是 QGIS、Blender、Cesium 共用图层,样式为浅色填充
`#fffff6`、深色描边 `#2b2b28`,正常外轮廓为七顶点箭头。
- `turn:lanes:forward=right``turn:lanes:backward=through;right` 等标准标签已存在于
南台子 OSM。
- `openstreetmap/lane-icons` 是 OSM 官方组织发布的 CC0 SVG 图标集;其 `DESIGN.md`
以 MUTCD Standard Arrow 为基准,并规定组合转向共享箭杆、箭头数量增加时缩小箭头。
仓库只提供左侧转向示例,右转及 `through;right` 需在几何层对其镜像,不能直接作为
当前 GeoJSON polygon 图层输入。
- 用户已决定将该库中首版需要的转向图标作为本仓库维护的源素材,而不是只作为视觉参考。
## Requirements
1. 在写入 `lane_arrows_webscale.geojson` 前,从 OSM 标准 `turn:lanes:*` 生成自定义箭头
polygon并沿用既有图层、材质、QGIS/Blender/Cesium 路径。
2. 支持首版实际需要的 `through``left``right``through;left``through;right`
同一 OSM 输入必须产生确定性结果。
3. 自定义箭头必须使用现有道路标线的视觉语言,不新建一套颜色、描边或图标风格。
4. 在 OSM way 进入路口的一端、按 forward/backward 和 lane index 放置;车道配置不完整或
端点不是可判定路口时给出可诊断的跳过原因。
5. 首先完成并获得用户对 `right``through;right` 轮廓样张的确认,之后才能将其接入
GeoJSON / Blender 导出。
6. 未经用户确认,不提交 Git。
7.`openstreetmap/lane-icons` 的全部箭头 SVG 和 CC0 许可证说明纳入本仓库维护;
右转变体由左转源图镜像生成。生成的 polygon 仍由本仓库维护,并继续使用
`lane_arrows_webscale` 的既有填充与描边样式。
8. 素材清单必须逐项标记验证状态:仅 `through``left``right``through;left`
`through;right` 在首版具备 OSM 映射与自动化测试;其他已收录箭头明确标为未测试,
且不得自动参与生产 GeoJSON 输出。
## Acceptance Criteria
- [ ] 用户确认箭头样张的轮廓与视觉语言。
- [ ] 目标 OSM 标签导致 lane-arrow GeoJSON 与移除这些标签的对照产生可观察差异。
- [ ] 自定义 arrow properties 标识来源和 OSM way / direction / lane便于诊断。
- [ ] 纯 Node 测试覆盖标签解析、forward/backward、lane placement、组合转向和幂等排序。
- [ ] 本地素材清单包含所有上游箭头,逐项标记 CC0 来源、支持状态和测试状态;未测试条目
不会参与生产映射。
- [ ] 仅南台子完整链和质量门验证通过。
- [ ] 无 Git 提交,直到用户明确确认。
## Out Of Scope
- 修改 osm2streets 依赖、道路改为 Cesium 直渲染、增加新的道路渲染图层。
- 支持首版范围之外的 OSM maneuver 词汇、匝道标线、文字或自行车专用箭头。
## Delivery Gate
在自定义箭头接入生产 GeoJSON / Blender 导出前,用户必须确认 `right`
`through;right` 样张的轮廓和视觉语言。

View File

@@ -0,0 +1,40 @@
# lane-icons research
Source: <https://github.com/openstreetmap/lane-icons>, inspected 2026-08-04.
## Provenance
- Owner: `openstreetmap` GitHub organization.
- Repository tree revision: `db3cab321da93a757c2cf3cdb4f1f1db6b1a091c` (`master`).
- License: `LICENSE.md` is CC0 1.0 Universal. Keep that text adjacent to any
vendored source assets.
## Relevant Contents
- `docs/through.svg`
- `docs/left.svg`
- `docs/left-through.svg`
- `docs/slight_left.svg`
- `docs/slight_left-through.svg`
- `docs/sharp_left.svg`
- `docs/sharp_left-through.svg`
- `docs/reverse_left.svg`
- `docs/reverse_left-through.svg`
- `docs/reverse_left-left-slight_left-through.svg`
- `docs/left-slight_left-through.svg`
## Design Rules
`DESIGN.md` bases arrows on the MUTCD Standard Arrow. It specifies a shared
shaft for compound arrows, smooth curves, aligned arrow bottoms, and reduced
arrowhead scale as the number of directions grows. The upstream SVGs are
single-color and stroke-free; our existing render layer supplies the project
fill and outline treatment.
## Adoption Decision
Vendor all listed arrow SVGs with provenance and CC0 license text. The
production mapping is initially restricted to `through`, `left`, `right`,
`through;left`, and `through;right`. Right-side variants are deterministic
mirrors of upstream left-side assets. Every other vendored arrow remains
explicitly untested and unavailable to the production mapper.

View File

@@ -0,0 +1,26 @@
{
"id": "add-osm-turn-lane-arrows",
"name": "add-osm-turn-lane-arrows",
"title": "Add OSM turn lane arrows",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-04",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,24 @@
{
"upstream": {
"repository": "https://github.com/openstreetmap/lane-icons",
"revision": "db3cab321da93a757c2cf3cdb4f1f1db6b1a091c",
"license": "CC0-1.0",
"license_file": "upstream/LICENSE.md"
},
"assets": [
{"id":"through","source":"upstream/through.svg","anchor_x":12.5,"supported":true,"tested":true,"template":"through"},
{"id":"left","source":"upstream/left.svg","anchor_x":17,"supported":true,"tested":true,"template":"left"},
{"id":"right","source":"upstream/left.svg","derived_from":"left","mirror_x":true,"anchor_x":17,"supported":true,"tested":true,"template":"right"},
{"id":"through;left","source":"upstream/left-through.svg","anchor_x":17,"supported":true,"tested":true,"template":"through_left"},
{"id":"through;right","source":"upstream/left-through.svg","derived_from":"through;left","mirror_x":true,"anchor_x":17,"supported":true,"tested":true,"template":"through_right"},
{"id":"through;left;right","source":"upstream/left-slight_left-through.svg","derived_from":"through;left + through;right","anchor_x":17,"supported":true,"tested":true,"template":"through_left_right"},
{"id":"slight_left","source":"upstream/slight_left.svg","supported":false,"tested":false},
{"id":"slight_left;through","source":"upstream/slight_left-through.svg","supported":false,"tested":false},
{"id":"left;slight_left;through","source":"upstream/left-slight_left-through.svg","supported":false,"tested":false},
{"id":"sharp_left","source":"upstream/sharp_left.svg","supported":false,"tested":false},
{"id":"sharp_left;through","source":"upstream/sharp_left-through.svg","supported":false,"tested":false},
{"id":"reverse_left","source":"upstream/reverse_left.svg","supported":false,"tested":false},
{"id":"reverse_left;through","source":"upstream/reverse_left-through.svg","supported":false,"tested":false},
{"id":"reverse_left;left;slight_left;through","source":"upstream/reverse_left-left-slight_left-through.svg","supported":false,"tested":false}
]
}

View File

@@ -0,0 +1,98 @@
## Design Guidelines
The icons in this project follow closely the design guidelines established in the
[MUTCD Standard Highway Signs guide](http://mutcd.fhwa.dot.gov/ser-shs_millennium.htm),
2009 edition published by the US Federal Highway Administration.
The MUTCD is [in the public domain](http://mutcd.fhwa.dot.gov/knowledge/faqs/faq_general.htm#printq5)
and as such it is not copyrighted.
Each icon should be
* 25px x 25px
* SVG
* Single color fill
* Path outline only, no stroke
* Pixel grid aligned (where possible)
### Arrows
Arrows use the "Standard Arrow" described in MUTCD Standard Highway Signs
[Appendix](http://mutcd.fhwa.dot.gov/ser-shs_millennium.htm).
An upward facing Standard Arrow is included in this icon pack, and forms the basis of all turn
lane arrows.
<img alt='Standard Arrow' width='300px' src='docs/standard_arrow.png'/>
Arrowhead rotation angles are chosen based on the turn lane indication:
<table>
<tr>
<th>Through</th>
<th>Slight Left</th>
<th>Left</th>
<th>Sharp Left</th>
<th>Reverse Left</th>
</tr>
<tr>
<td>no rotation<br/>
<img alt='Through' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/through.svg'/>
</td>
<td>45 degrees<br/>
<img alt='Slight Left' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/slight_left.svg'/>
</td>
<td>70 or 90 degrees<br/>
<img alt='Left' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/left.svg'/>
</td>
<td>135 degrees<br/>
<img alt='Sharp Left' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/sharp_left.svg'/>
</td>
<td>180 degrees<br/>
<img alt='Reverse Left' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/reverse_left.svg'/>
</td>
</tr>
</table>
As the number of arrows increases, the arrows scale down:
<table>
<tr>
<th>1 Arrow</th>
<th>2 Arrows</th>
<th>3 Arrows</th>
<th>4 Arrows</th>
</tr>
<tr>
<td>
<ul><li>3px stroke</li><li>standard arrowhead</li></ul>
<img alt='Through' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/through.svg'/>
</td>
<td>
<ul><li>2.5px stroke</li><li>83% scaled arrowhead</li></ul>
<img alt='Left, Through' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/left-through.svg'/>
</td>
<td>
<ul><li>2.25px stroke</li><li>75% scaled arrowhead</li></ul>
<img alt='Left, Slight Left, Through' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/left-slight_left-through.svg'/>
</td>
<td>
<ul><li>2px stroke</li><li>66% scaled arrowhead</li></ul>
<img alt='Reverse Left, Left, Slight Left, Through' width='100px' src='https://cdn.rawgit.com/openstreetmap/lane-icons/master/docs/reverse_left-left-slight_left-through.svg'/>
</td>
</tr>
</table>
Other important rules for arrows:
* Bottom aligned with each other, 2px from icon bottom edge
* Left-right centered
* Smooth curves, no kinks
* Arrow shaft path connects to the arrowhead anchor point
### Other Icons
Other non-arrow icons (bus, hov, pedestrian, tram, etc) also imitiate the icons found
in the MUTCD Standard Highway Signs [Appendix](http://mutcd.fhwa.dot.gov/ser-shs_millennium.htm).

View File

@@ -0,0 +1,118 @@
[![CC0-1.0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](http://creativecommons.org/publicdomain/zero/1.0/)
### CC0 1.0 Universal
#### Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
**1. Copyright and Related Rights.** A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
**2. Waiver.** To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
**3. Public License Fallback.** Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
**4. Limitations and Disclaimers.**
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>

View File

@@ -0,0 +1 @@
<svg id="left-slight_left-through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><line x1="18.1" y1="5.17" x2="18.11" y2="23" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.25"/><path d="M18.11,19.69c0-3.65-5.82-6.62-9.2-6.62" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.25"/><path d="M11.89,7.16S18.11,12.74,18.11,19" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.25"/><path d="M18.16,1.09a0.07,0.07,0,0,0-.12,0L15.26,5.36a0.42,0.42,0,0,0,.48.62l2.36-.81L20.47,6A0.42,0.42,0,0,0,21,5.37Z"/><path d="M4.83,13a0.07,0.07,0,0,0,0,.12L9.1,15.91a0.42,0.42,0,0,0,.62-0.48L8.91,13.07,9.72,10.7a0.42,0.42,0,0,0-.62-0.48Z"/><path d="M9.05,4.24A0.07,0.07,0,0,0,9,4.32l1,5a0.42,0.42,0,0,0,.78.1l1.1-2.24,2.25-1.1A0.42,0.42,0,0,0,14,5.28Z"/></svg>

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -0,0 +1 @@
<svg id="left-through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><line x1="17" y1="23" x2="17" y2="6.81" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/><path d="M17,19c0-5.32-4.66-8.23-7.83-9.1" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/><path d="M17.06,2.3a0.08,0.08,0,0,0-.13,0L13.85,7a0.47,0.47,0,0,0,.53.69L17,6.81l2.62,0.9A0.46,0.46,0,0,0,20.15,7Z"/><path d="M5,8.29a0.08,0.08,0,0,0,0,.12l3.38,4.51a0.47,0.47,0,0,0,.83-0.27L9.17,9.9l1.74-2.16A0.46,0.46,0,0,0,10.45,7Z"/></svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -0,0 +1 @@
<svg id="left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><path d="M17,23V18c0-6.78-3.24-8.11-5.85-8.86" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="3"/><path d="M6.07,7.21A0.1,0.1,0,0,0,6,7.36l4.08,5.44a0.56,0.56,0,0,0,1-.32l0.06-3.33,2.1-2.6a0.56,0.56,0,0,0-.55-0.88Z"/></svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1 @@
<svg id="reverse_left-left-slight_left-through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><line x1="19" y1="4" x2="19" y2="23" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/><path d="M7.21,12h5.5A15.61,15.61,0,0,1,19,13" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/><path d="M13.7,5.85S19,9.23,19,15" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/><path d="M19.05,0.43a0.07,0.07,0,0,0-.1,0L16.49,4.18a0.37,0.37,0,0,0,.42.55L19,4l2.09,0.71a0.37,0.37,0,0,0,.42-0.54Z"/><path d="M11.2,3.28a0.07,0.07,0,0,0-.07.07l0.92,4.39a0.37,0.37,0,0,0,.68.09l1-2,2-1A0.37,0.37,0,0,0,15.6,4.2Z"/><path d="M3.68,11.95a0.07,0.07,0,0,0,0,.1L7.44,14.5A0.37,0.37,0,0,0,8,14.08L7.21,12,8,9.91a0.37,0.37,0,0,0-.54-0.42Z"/><path d="M10,23.06a0.07,0.07,0,0,0,.1,0l2.45-3.76a0.37,0.37,0,0,0-.42-0.55L10,19.48l-2.09-.71a0.37,0.37,0,0,0-.42.54Z"/><path d="M19,16c0-5.42-9-4.84-9,0l0,3.48" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg id="reverse_left-through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><path d="M8.36,23a0.08,0.08,0,0,0,.13,0l3.08-4.72A0.47,0.47,0,0,0,11,17.56l-2.62.9-2.62-.9a0.46,0.46,0,0,0-.53.68Z"/><line x1="17" y1="23.19" x2="17" y2="6.08" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/><path d="M17.06,1.57a0.08,0.08,0,0,0-.13,0L13.85,6.29a0.47,0.47,0,0,0,.53.69L17,6.08,19.62,7a0.46,0.46,0,0,0,.53-0.68Z"/><path d="M17,16c0-5-8.58-5.08-8.58,0v2.46" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/></svg>

After

Width:  |  Height:  |  Size: 574 B

View File

@@ -0,0 +1 @@
<svg id="reverse_left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><path d="M7.8,23A0.1,0.1,0,0,0,8,23l3.72-5.69A0.56,0.56,0,0,0,11,16.45L7.88,17.53,4.72,16.44a0.56,0.56,0,0,0-.64.82Z"/><path d="M18,23V13c0-8-10.08-7.77-10.12.1v5.42" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1 @@
<svg id="sharp_left-through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><path d="M5.38,17.17a0.08,0.08,0,0,0,.09.09L11,16.1a0.47,0.47,0,0,0,.11-0.86L8.62,14,7.4,11.53a0.46,0.46,0,0,0-.86.11Z"/><line x1="17" y1="23.19" x2="17" y2="6.08" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/><path d="M17.06,1.57a0.08,0.08,0,0,0-.13,0L13.85,6.29a0.47,0.47,0,0,0,.53.69L17,6.08,19.62,7a0.46,0.46,0,0,0,.53-0.68Z"/><path d="M17,15.65c0-4.75-5.12-5.25-8.38-1.63" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/></svg>

After

Width:  |  Height:  |  Size: 580 B

View File

@@ -0,0 +1 @@
<svg id="sharp_left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><path d="M4.1,15.8a0.1,0.1,0,0,0,.11.11l6.65-1.4a0.56,0.56,0,0,0,.13-1L8,12,6.53,9a0.56,0.56,0,0,0-1,.13Z"/><path d="M18,23V15c0-5.78-4-7.79-8.61-4.11L8,12" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="3"/></svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1 @@
<svg id="slight_left-through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><line x1="17" y1="23.19" x2="17" y2="6.08" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/><path d="M17.06,1.57a0.08,0.08,0,0,0-.13,0L13.85,6.29a0.47,0.47,0,0,0,.53.69L17,6.08,19.62,7a0.46,0.46,0,0,0,.53-0.68Z"/><path d="M17,19c0-4.22-4.84-9.36-6.54-10.68" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2.5"/><path d="M7.3,5.09a0.08,0.08,0,0,0-.09.09L8.37,10.7a0.47,0.47,0,0,0,.86.11l1.22-2.48,2.49-1.22a0.46,0.46,0,0,0-.11-0.86Z"/></svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -0,0 +1 @@
<svg id="slight_left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><path d="M15,23V20c0-4.12-.1-9.29-3-12.74" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="3"/><path d="M8.26,3.36a0.1,0.1,0,0,0-.11.11l1.4,6.65a0.56,0.56,0,0,0,1,.13l1.46-3,3-1.47a0.56,0.56,0,0,0-.13-1Z"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1 @@
<svg id="through" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><title>icons</title><line x1="12.5" y1="23" x2="12.5" y2="8.47" fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="3"/><path d="M12.58,3a0.1,0.1,0,0,0-.16,0L8.7,8.73a0.56,0.56,0,0,0,.64.83L12.5,8.47l3.16,1.08a0.56,0.56,0,0,0,.64-0.82Z"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -21,6 +21,9 @@
"previewExtent": null,
"layerPrefix": "osm2streets"
},
"turnLaneArrows": {
"enabled": true
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -21,6 +21,9 @@
"previewExtent": null,
"layerPrefix": "osm2streets"
},
"turnLaneArrows": {
"enabled": false
},
"osm2streets": {
"debug_each_step": false,
"dual_carriageway_experiment": false,

View File

@@ -13,7 +13,9 @@
"preflight:area": "node scripts/preflight-area.js",
"test:preflight": "node scripts/test-area-preflight.js",
"test:budgets": "node scripts/test-asset-budgets.js",
"test:preview-assets": "node scripts/test-preview-assets.js"
"test:preview-assets": "node scripts/test-preview-assets.js",
"test:turn-lane-arrows": "node scripts/test-turn-lane-arrows.js",
"render:turn-lane-arrow-samples": "node scripts/render-turn-lane-arrow-samples.js"
},
"dependencies": {
"osm2streets-js-node": "0.1.4"

View File

@@ -161,6 +161,7 @@ function writeDerivedConfig(area) {
canvasExtent: area.qgis.canvasExtent,
previewExtent: area.qgis.previewExtent,
layerPrefix: area.qgis.layerPrefix,
turnLaneArrows: area.turnLaneArrows,
osm2streets: area.osm2streets,
};
const derivedConfigPath = path.join(area.outputs.pipelineDir, "osm2streets-qgis.config.json");

View File

@@ -5,6 +5,7 @@ const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const { JsStreetNetwork } = require("osm2streets-js-node");
const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows");
const {
SCENE_LAYERS,
SCENE_FILE,
@@ -88,6 +89,29 @@ const split = splitLayers(
intersectionCornerSourceMaxDimensionMeters,
osm,
);
const customTurnLaneArrows = buildCustomTurnLaneArrows(osm, {
...config.turnLaneArrows,
network: JSON.parse(network.toJson()),
lanePolygons: JSON.parse(fs.readFileSync(path.join(outDir, "lane_polygons.geojson"), "utf8")).features,
crosswalkStripes: split.crosswalks.features,
stopLines: split.vehicleStopLines.features,
});
const suppressedStandardLaneArrows = suppressNearestStandardLaneArrows(
split.laneArrows.features,
customTurnLaneArrows.features,
);
split.laneArrows.features = split.laneArrows.features.filter((feature) => !suppressedStandardLaneArrows.has(feature));
split.laneArrows.features.push(...customTurnLaneArrows.features);
const turnLaneArrowDiagnostics = {
enabled: config.turnLaneArrows?.enabled === true,
generated: customTurnLaneArrows.features.length,
suppressed_standard_lane_arrows: suppressedStandardLaneArrows.size,
diagnostics: customTurnLaneArrows.diagnostics,
};
fs.writeFileSync(
path.join(outDir, "turn_lane_arrow_diagnostics.json"),
`${JSON.stringify(turnLaneArrowDiagnostics, null, 2)}\n`,
);
for (const layer of SCENE_LAYERS) {
writeJson(path.join(outDir, layerFile(layer)), split[layer.splitKey]);
}
@@ -410,7 +434,7 @@ function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) {
const markings = JSON.parse(fs.readFileSync(path.join(dir, "lane_markings.geojson"), "utf8"));
const intersections = JSON.parse(fs.readFileSync(path.join(dir, "intersection_markings.geojson"), "utf8"));
const network = JSON.parse(fs.readFileSync(path.join(dir, "network.json"), "utf8"));
const crosswalkData = buildCrosswalks(osm);
const crosswalkData = buildCrosswalks(osm, lanePolygons.features);
const serviceWayIds = new Set([...osm.ways.values()]
.filter((way) => way.tags.highway === "service")
.map((way) => way.id));
@@ -452,9 +476,6 @@ function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) {
const conflictsWithCrosswalk = isInAnyPolygon(feature, crosswalkData.zones, "intersects");
if (type === "lane separator" && !conflictsWithCrosswalk) out.laneSeparators.features.push(feature);
if (type === "center line" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) out.centerLines.features.push(feature);
if (type === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) {
out.vehicleStopLines.features.push(feature);
}
if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) {
out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue));
}
@@ -987,6 +1008,43 @@ function featureCenter(feature) {
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
}
function suppressNearestStandardLaneArrows(standardFeatures, customFeatures) {
const groups = new Map();
for (const feature of customFeatures) {
const id = feature.properties?.custom_arrow_id;
if (!id) continue;
const group = groups.get(id) || [];
group.push(feature);
groups.set(id, group);
}
const suppressed = new Set();
for (const parts of groups.values()) {
const center = featureGroupCenter(parts);
if (!center) continue;
let closest = null;
for (const feature of standardFeatures) {
if (suppressed.has(feature)) continue;
const candidateCenter = featureCenter(feature);
if (!candidateCenter) continue;
const distance = lineLengthMeters(center, candidateCenter);
if (distance > 5.5 || (closest && distance >= closest.distance)) continue;
closest = { feature, distance };
}
if (closest) suppressed.add(closest.feature);
}
return suppressed;
}
function featureGroupCenter(features) {
const points = [];
for (const feature of features) collectCoords(feature.geometry?.coordinates, points);
if (!points.length) return null;
return [
(Math.min(...points.map((point) => point[0])) + Math.max(...points.map((point) => point[0]))) / 2,
(Math.min(...points.map((point) => point[1])) + Math.max(...points.map((point) => point[1]))) / 2,
];
}
function pointInPolygon(point, rings) {
if (!rings?.length || !pointInRing(point, rings[0])) return false;
return !rings.slice(1).some((ring) => pointInRing(point, ring));
@@ -1006,31 +1064,29 @@ function pointInRing([x, y], ring) {
return inside;
}
function buildCrosswalks(osm) {
function buildCrosswalks(osm, lanePolygons = []) {
const crossingNodes = markedCrossingNodes(osm);
const clusterCenters = crossingClusters(crossingNodes);
const stripes = emptyCollection();
const stopLines = emptyCollection();
const zones = [];
const stopLineExclusionZones = [];
const fixedCenters = [];
for (const node of crossingNodes) {
const way = findCrossingWay(osm, node.id);
const vector = crossingVector(osm, way, node.id);
if (!vector) continue;
const crosswalk = crosswalkGeometry(node, way, vector, clusterCenters.get(node.id));
const crosswalk = crosswalkGeometry(node, way, vector, clusterCenters.get(node.id), lanePolygons);
if (!crosswalk) continue;
if (fixedCenters.some((center) => pointDistanceMeters(center, crosswalk.center, crosswalk.meters) < 1)) continue;
fixedCenters.push(crosswalk.center);
stripes.features.push(...crosswalk.stripes);
if (crosswalk.stopLine) stopLines.features.push(crosswalk.stopLine);
zones.push({
bbox: featureBounds(crosswalk.zone),
rings: polygonRings(crosswalk.zone.geometry),
});
stopLineExclusionZones.push({
bbox: featureBounds(crosswalk.stopLineExclusionZone),
rings: polygonRings(crosswalk.stopLineExclusionZone.geometry),
});
}
return { stripes, stopLines, zones, stopLineExclusionZones };
return { stripes, stopLines, zones };
}
function markedCrossingNodes(osm) {
@@ -1098,19 +1154,21 @@ function crossingVector(osm, way, nodeId) {
return null;
}
function crosswalkGeometry(node, way, vector, intersectionCenter) {
function crosswalkGeometry(node, way, vector, intersectionCenter, lanePolygons) {
const meters = metersForLat(node.lat);
const stripeLength = crosswalkLengthMeters(way);
const stripeWidth = 0.45;
const gap = 0.45;
const count = 6;
const total = count * stripeWidth + (count - 1) * gap;
const roadUnit = normalizeMetersVector(vector, meters);
if (!roadUnit) return null;
const rawRoadUnit = normalizeMetersVector(vector, meters);
if (!rawRoadUnit) return null;
const provisionalCenter = fixedCrosswalkCenter(node, rawRoadUnit, intersectionCenter, meters);
const laneFrame = crosswalkLaneFrame(way, lanePolygons, provisionalCenter, rawRoadUnit, meters);
const roadUnit = laneFrame?.roadUnit || rawRoadUnit;
const acrossUnit = [-roadUnit[1], roadUnit[0]];
const center = [node.lon, node.lat];
const center = laneFrame?.center || provisionalCenter;
const zoneCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 0.8, total + 0.8, meters);
const stopLineExclusionCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 5, total + 1.5, meters);
const zone = {
type: "Feature",
properties: {
@@ -1119,14 +1177,6 @@ function crosswalkGeometry(node, way, vector, intersectionCenter) {
},
geometry: { type: "Polygon", coordinates: [zoneCoords] },
};
const stopLineExclusionZone = {
type: "Feature",
properties: {
type: "crosswalk stop line exclusion zone",
crossing_node_id: node.id,
},
geometry: { type: "Polygon", coordinates: [stopLineExclusionCoords] },
};
const stripes = [];
for (let i = 0; i < count; i += 1) {
const offset = -total / 2 + stripeWidth / 2 + i * (stripeWidth + gap);
@@ -1144,17 +1194,92 @@ function crosswalkGeometry(node, way, vector, intersectionCenter) {
}
return {
stripes,
center,
meters,
zone,
stopLineExclusionZone,
stopLine: syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters),
stopLine: syntheticStopLine(center, node.id, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters),
};
}
function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) {
function crosswalkLaneFrame(way, lanePolygons, provisionalCenter, rawRoadUnit, meters) {
if (!way || !Array.isArray(lanePolygons)) return null;
const candidates = lanePolygons
.filter((feature) => feature.properties?.type === "Driving" && hasAnyWayId(feature.properties?.osm_way_ids, new Set([way.id])))
.map((feature) => nearestLaneAnchor(feature, provisionalCenter, meters))
.filter(Boolean)
.sort((a, b) => a.distance - b.distance);
if (!candidates.length) return null;
// A way may be represented by adjacent normalized road pieces. Keep the
// nearby cross-section, including every directional lane, not a distant
// piece that happens to retain the same OSM way ID.
const maxDistance = candidates[0].distance + 8;
const anchors = candidates.filter((anchor) => anchor.distance <= maxDistance);
if (!anchors.length) return null;
const center = anchors.reduce((sum, anchor) => [sum[0] + anchor.point[0], sum[1] + anchor.point[1]], [0, 0])
.map((value) => value / anchors.length);
const axis = anchors.reduce((sum, anchor) => {
const sign = anchor.tangent[0] * rawRoadUnit[0] + anchor.tangent[1] * rawRoadUnit[1] >= 0 ? 1 : -1;
return [sum[0] + anchor.tangent[0] * sign, sum[1] + anchor.tangent[1] * sign];
}, [0, 0]);
const roadUnit = normalizeMetersVector(axis, { lon: 1, lat: 1 });
return roadUnit ? { center, roadUnit } : null;
}
function nearestLaneAnchor(feature, point, meters) {
const centerline = drivingLaneCenterline(feature);
if (!centerline) return null;
let best = null;
for (let index = 0; index < centerline.length - 1; index += 1) {
const start = centerline[index];
const end = centerline[index + 1];
const closest = closestPointOnSegment(point, start, end, meters);
const distance = pointDistanceMeters(closest, point, meters);
const tangent = normalizeMetersVector([end[0] - start[0], end[1] - start[1]], meters);
if (tangent && (!best || distance < best.distance)) best = { point: closest, distance, tangent };
}
return best;
}
function drivingLaneCenterline(feature) {
const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null;
if (!ring || ring.length < 5) return null;
const vertices = ring.slice(0, -1);
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
return vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
}
function closestPointOnSegment(point, start, end, meters) {
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
}
function fixedCrosswalkCenter(node, roadUnit, intersectionCenter, meters) {
if (!intersectionCenter) return [node.lon, node.lat];
const center = [intersectionCenter.lon, intersectionCenter.lat];
const candidates = [
addMeters(center, roadUnit, 7, meters),
addMeters(center, roadUnit, -7, meters),
];
const source = [node.lon, node.lat];
return pointDistanceMeters(candidates[0], source, meters) <= pointDistanceMeters(candidates[1], source, meters)
? candidates[0]
: candidates[1];
}
function syntheticStopLine(crosswalkCenter, crossingNodeId, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) {
if (!intersectionCenter) return null;
const offset = stripeLength / 2 + 1.2;
const candidateA = addMeters([node.lon, node.lat], roadUnit, offset, meters);
const candidateB = addMeters([node.lon, node.lat], roadUnit, -offset, meters);
const candidateA = addMeters(crosswalkCenter, roadUnit, offset, meters);
const candidateB = addMeters(crosswalkCenter, roadUnit, -offset, meters);
const stopSide = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters)
? 1
: -1;
@@ -1167,7 +1292,7 @@ function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crossw
properties: {
type: "vehicle stop line",
source: "crosswalk",
crossing_node_id: node.id,
crossing_node_id: crossingNodeId,
highway: way?.tags.highway || null,
stop_side: stopSide === 1 ? "with_way_outside" : "against_way_outside",
},

View File

@@ -82,6 +82,9 @@ function normalizeAreaConfig(raw, options = {}) {
previewExtent: raw.qgis?.previewExtent ?? raw.previewExtent ?? null,
layerPrefix: raw.qgis?.layerPrefix ?? raw.layerPrefix ?? "osm2streets",
},
turnLaneArrows: {
enabled: booleanOption(raw.turnLaneArrows?.enabled, false, "turnLaneArrows.enabled"),
},
osm2streets: raw.osm2streets || {
debug_each_step: false,
dual_carriageway_experiment: false,

View File

@@ -0,0 +1,506 @@
"use strict";
const fs = require("fs");
const path = require("path");
const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "assets", "lane-icons", "manifest.json");
const LANE_WIDTH_METERS = 3.2;
const PLACEMENT_DISTANCE_METERS = 9;
const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18;
const SPATIAL_MATCH_MIN_ALIGNMENT = Math.cos(Math.PI / 6);
// Existing osm2streets lane arrows are approximately 1.4 m across. Keep the
// 25-unit upstream icon at the same on-road scale rather than at screen scale.
const SVG_METERS_PER_UNIT = 0.10;
function loadManifest(file = ASSET_MANIFEST) {
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
if (!Array.isArray(manifest.assets)) throw new Error("turn-lane asset manifest has no assets array");
return manifest;
}
function supportedAssets(manifest = loadManifest()) {
return new Map(manifest.assets
.filter((asset) => asset.supported === true && asset.tested === true)
.map((asset) => [asset.id, asset]));
}
function buildCustomTurnLaneArrows(osm, options = {}) {
const enabled = options.enabled === true;
const diagnostics = [];
if (!enabled) return { features: [], diagnostics: [{ reason: "disabled" }] };
const assets = supportedAssets(options.manifest);
const endpointRoadCounts = roadCountsByNode(osm);
const networkIntersectionNodes = new Set((options.network?.intersections || [])
.flatMap(([, intersection]) => intersection.osm_ids || []).map(Number));
const features = [];
const ways = [...osm.ways.values()].sort((a, b) => a.id - b.id);
for (const way of ways) {
for (const direction of ["forward", "backward"]) {
const tag = way.tags[`turn:lanes:${direction}`];
if (!tag) continue;
const laneCount = directionalLaneCount(way, direction);
if (!laneCount) {
diagnostics.push(skip(way, direction, "missing_lane_count"));
continue;
}
const endpoint = endpointGeometry(osm, way, direction, endpointRoadCounts, networkIntersectionNodes);
if (!endpoint) {
diagnostics.push(skip(way, direction, "indeterminate_intersection_endpoint"));
continue;
}
const maneuvers = String(tag).split("|").map((value) => normalizeManeuver(value));
for (let laneIndex = 0; laneIndex < maneuvers.length; laneIndex += 1) {
const maneuver = maneuvers[laneIndex];
const asset = assets.get(maneuver);
if (!asset) {
diagnostics.push(skip(way, direction, "unsupported_or_untested_maneuver", { lane_index: laneIndex, maneuver }));
continue;
}
if (laneIndex >= laneCount) {
diagnostics.push(skip(way, direction, "lane_index_exceeds_lane_count", { lane_index: laneIndex, maneuver }));
continue;
}
const resolvedPlacement = lanePlacement(way, direction, laneIndex, endpoint, options.lanePolygons, options.crosswalkStripes, options.stopLines);
if (resolvedPlacement?.blocked) {
diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver }));
continue;
}
const placement = resolvedPlacement || fallbackLanePlacement(endpoint, direction, laneIndex, options.crosswalkStripes, options.stopLines);
if (!placement) {
diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver }));
continue;
}
const parts = templateFor(asset.id, options.manifest);
for (let partIndex = 0; partIndex < parts.length; partIndex += 1) {
features.push(makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, parts[partIndex], placement.center, placement));
}
}
}
}
return { features, diagnostics };
}
function normalizeManeuver(value) {
const parts = String(value || "").split(";").map((part) => part.trim()).filter(Boolean).sort();
const supported = new Map([
["through", "through"], ["left", "left"], ["right", "right"],
["left;through", "through;left"], ["right;through", "through;right"],
["left;right;through", "through;left;right"],
]);
return supported.get(parts.join(";")) || parts.join(";");
}
function directionalLaneCount(way, direction) {
const specific = Number(way.tags[`lanes:${direction}`]);
if (Number.isInteger(specific) && specific > 0) return specific;
const total = Number(way.tags.lanes);
if (Number.isInteger(total) && total > 0 && total % 2 === 0 && !isOneway(way)) return total / 2;
if (Number.isInteger(total) && total > 0 && isOneway(way)) return total;
return null;
}
function roadCountsByNode(osm) {
const out = new Map();
for (const way of osm.ways.values()) {
if (!way.tags.highway || way.tags.highway === "service") continue;
for (const ref of new Set(way.refs)) out.set(ref, (out.get(ref) || 0) + 1);
}
return out;
}
function endpointGeometry(osm, way, direction, roadCounts, networkIntersectionNodes) {
const forward = direction === "forward";
const endpointIndex = forward ? way.refs.length - 1 : 0;
const neighborIndex = forward ? endpointIndex - 1 : 1;
const node = osm.nodes.get(way.refs[endpointIndex]);
const neighbor = osm.nodes.get(way.refs[neighborIndex]);
if (!node || !neighbor) return null;
const networkSaysIntersection = networkIntersectionNodes && networkIntersectionNodes.size > 0 && networkIntersectionNodes.has(node.id);
if (!networkSaysIntersection && (roadCounts.get(node.id) || 0) < 3) return null;
const meters = metersForLat(node.lat);
// For both directions, point from the adjacent road node to the endpoint.
// At a forward endpoint this is the OSM-way direction; at a backward
// endpoint it is the reverse OSM-way direction, i.e. the actual travel
// direction used by turn:lanes:backward.
const raw = [node.lon - neighbor.lon, node.lat - neighbor.lat];
const axis = normalizeMetersVector(raw, meters);
if (!axis) return null;
return { node, axis, right: [axis[1], -axis[0]], meters };
}
function laneCenter(endpoint, direction, laneIndex, meters) {
const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS;
// The local axis always follows travel, so moving back from either endpoint
// places the marking on its approach lane before the intersection.
return addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -PLACEMENT_DISTANCE_METERS, endpoint.right, lateral, meters);
}
function fallbackLanePlacement(endpoint, direction, laneIndex, crosswalkStripes, stopLines) {
const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS;
for (const distance of [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]) {
const center = addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -distance, endpoint.right, lateral, endpoint.meters);
if (!nearIntersectionMarking(center, endpoint.axis, crosswalkStripes, stopLines, endpoint.meters)) {
return { center, axis: endpoint.axis, right: endpoint.right, meters: endpoint.meters, placementDistance: distance, placementSource: "osm_way_fallback" };
}
}
return null;
}
function lanePlacement(way, direction, laneIndex, endpoint, lanePolygons, crosswalkStripes, stopLines) {
if (!Array.isArray(lanePolygons)) return null;
const expectedDirection = direction === "forward" ? "Fwd" : "Back";
const directionalCandidates = lanePolygons.filter((feature) =>
feature.properties?.type === "Driving" &&
feature.properties.direction === expectedDirection
);
let candidates = directionalCandidates.filter((feature) =>
(feature.properties.osm_way_ids || []).map(Number).includes(way.id)
);
let placementSource = "driving_lane_centerline";
let spatialAnchors = null;
if (!candidates.length) {
const ranked = directionalCandidates
.map((feature) => ({ feature, anchor: spatialLaneAnchor(feature, endpoint) }))
.filter(({ anchor }) => anchor)
.filter(({ anchor }) => anchor.alignment >= SPATIAL_MATCH_MIN_ALIGNMENT && anchor.distance <= SPATIAL_MATCH_MAX_DISTANCE_METERS)
.sort((a, b) => a.anchor.distance - b.anchor.distance || a.anchor.lateral - b.anchor.lateral || Number(a.feature.properties.index) - Number(b.feature.properties.index));
if (ranked.length) {
// JOSM may split a tagged OSM way into temporary negative IDs. Those IDs
// are absent from osm2streets' rendered polygons, so associate the full
// physical approach by endpoint proximity and road-axis alignment.
candidates = ranked.map(({ feature }) => feature);
placementSource = "spatial_driving_lane_centerline";
spatialAnchors = new Map(ranked.map(({ feature, anchor }) => [feature, anchor]));
}
}
candidates.sort((a, b) => {
const lateralA = spatialAnchors?.get(a)?.lateral;
const lateralB = spatialAnchors?.get(b)?.lateral;
if (Number.isFinite(lateralA) && Number.isFinite(lateralB) && lateralA !== lateralB) return lateralA - lateralB;
return Number(a.properties.index) - Number(b.properties.index);
});
const lane = candidates[laneIndex];
const spatialAnchor = spatialAnchors?.get(lane);
if (spatialAnchor) {
const sampled = placementDistances().map((distance) => ({
center: sampleCenterlineAwayFromEndpoint(spatialAnchor, distance, endpoint.meters),
distance,
})).find(({ center }) => center && !nearIntersectionMarking(center, spatialAnchor.axis, crosswalkStripes, stopLines, endpoint.meters));
if (!sampled) return { blocked: true };
return { center: sampled.center, axis: spatialAnchor.axis, right: [spatialAnchor.axis[1], -spatialAnchor.axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource };
}
const centerline = laneCenterline(lane);
if (!centerline) return null;
const startsAtEndpoint = direction === "backward";
const ordered = startsAtEndpoint ? centerline : [...centerline].reverse();
const sampled = [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]
.map((distance) => ({ center: samplePolyline(ordered, distance, endpoint.meters), distance }))
.find(({ center }) => center && !nearIntersectionMarking(center, axisForLane(ordered, endpoint.meters), crosswalkStripes, stopLines, endpoint.meters));
if (!sampled) return { blocked: true };
const axis = axisForLane(ordered, endpoint.meters);
if (!axis) return null;
return { center: sampled.center, axis, right: [axis[1], -axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource };
}
function spatialLaneAnchor(lane, endpoint) {
const centerline = laneCenterline(lane);
if (!centerline) return null;
let best = null;
for (let index = 0; index < centerline.length - 1; index += 1) {
const start = centerline[index];
const end = centerline[index + 1];
const point = closestPointOnSegment([endpoint.node.lon, endpoint.node.lat], start, end, endpoint.meters);
const distance = Math.hypot((point[0] - endpoint.node.lon) * endpoint.meters.lon, (point[1] - endpoint.node.lat) * endpoint.meters.lat);
const tangent = normalizeMetersVector(subtractPoint(end, start), endpoint.meters);
if (!tangent || (best && distance >= best.distance)) continue;
const dot = tangent[0] * endpoint.axis[0] + tangent[1] * endpoint.axis[1];
const axis = dot >= 0 ? tangent : [-tangent[0], -tangent[1]];
const offset = subtractPoint(point, [endpoint.node.lon, endpoint.node.lat]);
best = {
point,
distance,
axis,
alignment: Math.abs(dot),
lateral: offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat,
centerline,
segmentIndex: index,
};
}
return best;
}
function placementDistances() {
return [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42];
}
function sampleCenterlineAwayFromEndpoint(anchor, distanceMeters, meters) {
const { centerline, segmentIndex } = anchor;
const start = centerline[segmentIndex];
const end = centerline[segmentIndex + 1];
const tangent = normalizeMetersVector(subtractPoint(end, start), meters);
if (!tangent) return null;
// Walk away from the junction along the rendered centerline. This preserves
// curved or split lane geometry instead of approximating it with a tangent.
const towardEnd = tangent[0] * anchor.axis[0] + tangent[1] * anchor.axis[1] < 0;
const points = [anchor.point];
if (towardEnd) {
for (let index = segmentIndex + 1; index < centerline.length; index += 1) points.push(centerline[index]);
} else {
for (let index = segmentIndex; index >= 0; index -= 1) points.push(centerline[index]);
}
return samplePolyline(points, distanceMeters, meters);
}
function closestPointOnSegment(point, start, end, meters) {
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
}
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
// A straight osm2streets Driving lane is commonly a closed quadrilateral:
// four distinct vertices plus the repeated closing vertex. Its opposing
// edges still provide the same two-point centerline as longer lane shapes.
if (!ring || ring.length < 5) return null;
// osm2streets Driving polygons are ordered along one boundary then back
// along the other. Midpoints of paired vertices form the rendered lane axis.
const vertices = ring.slice(0, -1);
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
return vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
}
function axisForLane(ordered, meters) {
return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters);
}
function nearIntersectionMarking(center, axis, stripes, stopLines, meters) {
if (!axis) return true;
const right = [axis[1], -axis[0]];
const samples = [];
for (const forward of [-0.2, 0.5, 1.2, 1.9, 2.2]) {
for (const lateral of [-1.6, -0.8, 0, 0.8, 1.6]) {
samples.push(addMeters(center, axis, forward, right, lateral, meters));
}
}
return [...(stripes || []), ...(stopLines || [])].some((feature) => samples.some((point) => nearFeature(point, feature, meters)));
}
function nearFeature(point, feature, meters) {
const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null;
if (!ring?.length) return false;
const xs = ring.map((coordinate) => coordinate[0]);
const ys = ring.map((coordinate) => coordinate[1]);
const clearance = 0.7;
const dx = Math.max((Math.min(...xs) - point[0]) * meters.lon, 0, (point[0] - Math.max(...xs)) * meters.lon);
const dy = Math.max((Math.min(...ys) - point[1]) * meters.lat, 0, (point[1] - Math.max(...ys)) * meters.lat);
return Math.hypot(dx, dy) < clearance;
}
function subtractPoint([lon, lat], [otherLon, otherLat]) {
return [lon - otherLon, lat - otherLat];
}
function samplePolyline(points, distanceMeters, meters) {
let remaining = distanceMeters;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const vector = normalizeMetersVector(subtractPoint(end, start), meters);
const length = Math.hypot((end[0] - start[0]) * meters.lon, (end[1] - start[1]) * meters.lat);
if (!vector || !length) continue;
if (remaining <= length) return addMeters(start, vector, remaining, [0, 0], 0, meters);
remaining -= length;
}
return null;
}
function makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, template, center, endpoint) {
const ring = template.map(([rightMeters, forwardMeters]) => addMeters(center, endpoint.axis, forwardMeters, endpoint.right, rightMeters, endpoint.meters));
return {
type: "Feature",
properties: {
type: "lane arrow",
source: "osm_turn_lanes",
osm_way_id: way.id,
direction,
lane_index: laneIndex,
maneuver,
source_asset: asset.id,
source_asset_path: asset.source,
arrow_part: partIndex,
// SVG strokes and fills are expanded separately for GeoJSON validity.
// This stable key lets the QGIS normalizer restore one rendered arrow.
custom_arrow_id: `${way.id}:${direction}:${laneIndex}:${maneuver}`,
placement_distance_meters: endpoint.placementDistance ?? PLACEMENT_DISTANCE_METERS,
placement_source: endpoint.placementSource ?? "osm_way_fallback",
},
geometry: { type: "Polygon", coordinates: [ring] },
};
}
function skip(way, direction, reason, extra = {}) {
return { source: "osm_turn_lanes", osm_way_id: way.id, direction, reason, ...extra };
}
function isOneway(way) {
return ["yes", "true", "1"].includes(String(way.tags.oneway || "").toLowerCase());
}
function metersForLat(lat) {
return { lon: 111320 * Math.cos((lat * Math.PI) / 180), lat: 110540 };
}
function normalizeMetersVector([dxLon, dyLat], meters) {
const x = dxLon * meters.lon;
const y = dyLat * meters.lat;
const length = Math.hypot(x, y);
return length ? [x / length, y / length] : null;
}
function addMeters(center, axis, axisDistance, right, rightDistance, meters) {
return [
center[0] + (axis[0] * axisDistance + right[0] * rightDistance) / meters.lon,
center[1] + (axis[1] * axisDistance + right[1] * rightDistance) / meters.lat,
];
}
function templateFor(assetId, manifest = loadManifest()) {
const asset = supportedAssets(manifest).get(assetId);
if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`);
return angularTemplate(assetId);
}
function angularTemplate(assetId) {
const shaftWidth = 0.30;
const shaftHalf = shaftWidth / 2;
const straightBase = 1.18;
const straightTip = 1.92;
const rectangle = (minX, minY, maxX, maxY) => [
[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY], [minX, minY],
];
const throughHead = () => [[0, straightTip], [-0.42, straightBase], [-shaftHalf, straightBase], [-shaftHalf, 0], [shaftHalf, 0], [shaftHalf, straightBase], [0.42, straightBase], [0, straightTip]];
const diagonalShaft = (side) => {
const start = [0, 0.56];
const end = [side * 0.72, 0.96];
const length = Math.hypot(end[0] - start[0], end[1] - start[1]);
const normal = [-(end[1] - start[1]) / length * shaftHalf, (end[0] - start[0]) / length * shaftHalf];
return [[start[0] + normal[0], start[1] + normal[1]], [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]], [start[0] - normal[0], start[1] - normal[1]], [start[0] + normal[0], start[1] + normal[1]]];
};
const diagonalHead = (side) => {
const base = [side * 0.60, 0.89];
const tip = [side * 1.22, 1.24];
const length = Math.hypot(tip[0] - base[0], tip[1] - base[1]);
const normal = [-(tip[1] - base[1]) / length * 0.36, (tip[0] - base[0]) / length * 0.36];
return [tip, [base[0] + normal[0], base[1] + normal[1]], [base[0] - normal[0], base[1] - normal[1]], tip];
};
const turnStem = (side) => {
const cutMidpoint = 0.73;
const cutRise = side * 0.084;
return [
[-shaftHalf, 0], [shaftHalf, 0],
[shaftHalf, cutMidpoint + cutRise], [-shaftHalf, cutMidpoint - cutRise],
[-shaftHalf, 0],
];
};
if (assetId === "through") return [throughHead()];
if (assetId === "right") return [turnStem(1), diagonalShaft(1), diagonalHead(1)];
if (assetId === "left") return [turnStem(-1), diagonalShaft(-1), diagonalHead(-1)];
if (assetId === "through;right") return [throughHead(), diagonalShaft(1), diagonalHead(1)];
if (assetId === "through;left") return [throughHead(), diagonalShaft(-1), diagonalHead(-1)];
if (assetId === "through;left;right") return [throughHead(), diagonalShaft(-1), diagonalHead(-1), diagonalShaft(1), diagonalHead(1)];
throw new Error(`No angular turn-lane template: ${assetId}`);
}
function sourceSvgTemplateFor(asset, assetId) {
const source = fs.readFileSync(path.resolve(__dirname, "..", "..", "assets", "lane-icons", asset.source), "utf8");
const mirrorX = asset.mirror_x === true;
const anchorX = Number(asset.anchor_x);
if (!Number.isFinite(anchorX)) throw new Error(`turn-lane asset has invalid anchor_x: ${assetId}`);
const shapes = [];
for (const match of source.matchAll(/<line\b([^>]*)\/>|<path\b([^>]*)\/>/g)) {
const attrs = parseSvgAttrs(match[1] || match[2]);
const strokeWidth = Number(attrs["stroke-width"] || 0);
if (match[1]) {
shapes.push(strokePolygon([[Number(attrs.x1), Number(attrs.y1)], [Number(attrs.x2), Number(attrs.y2)]], strokeWidth));
} else {
const points = parseSvgPath(attrs.d || "");
if (attrs.fill !== "none") shapes.push(points);
if (strokeWidth > 0) shapes.push(strokePolygon(points, strokeWidth));
}
}
return shapes.filter((ring) => ring.length >= 4).map((ring) => ring.map(([x, y]) => [
(mirrorX ? anchorX - x : x - anchorX) * SVG_METERS_PER_UNIT,
(23 - y) * SVG_METERS_PER_UNIT,
]));
}
function parseSvgAttrs(text) {
const attrs = {};
for (const match of text.matchAll(/([\w:-]+)=(['"])(.*?)\2/g)) attrs[match[1]] = match[3];
return attrs;
}
function parseSvgPath(value) {
const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || [];
let index = 0;
let command = "";
let point = [0, 0];
let start = null;
const points = [];
const number = () => Number(tokens[index++]);
const lineTo = (x, y) => { point = [x, y]; points.push(point); };
while (index < tokens.length) {
if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++];
const relative = command === command.toLowerCase();
const op = command.toUpperCase();
if (op === "Z") { if (start) points.push(start); command = ""; continue; }
if (op === "M" || op === "L") {
const x = number(); const y = number();
const next = relative ? [point[0] + x, point[1] + y] : [x, y];
if (op === "M" && !start) { start = next; point = next; points.push(point); command = relative ? "l" : "L"; } else lineTo(...next);
continue;
}
if (op === "H") { lineTo(relative ? point[0] + number() : number(), point[1]); continue; }
if (op === "V") { lineTo(point[0], relative ? point[1] + number() : number()); continue; }
if (op === "C") {
const values = [number(), number(), number(), number(), number(), number()];
const controls = relative ? values.map((n, i) => n + point[i % 2]) : values;
const origin = point;
for (let step = 1; step <= 8; step += 1) {
const t = step / 8; const u = 1 - t;
lineTo(u ** 3 * origin[0] + 3 * u ** 2 * t * controls[0] + 3 * u * t ** 2 * controls[2] + t ** 3 * controls[4], u ** 3 * origin[1] + 3 * u ** 2 * t * controls[1] + 3 * u * t ** 2 * controls[3] + t ** 3 * controls[5]);
}
continue;
}
if (op === "A") { number(); number(); number(); number(); number(); const x = number(); const y = number(); lineTo(relative ? point[0] + x : x, relative ? point[1] + y : y); continue; }
throw new Error(`Unsupported SVG path command: ${command}`);
}
return points;
}
function strokePolygon(points, width) {
if (points.length < 2) return [];
const half = width / 2;
const left = []; const right = [];
for (let index = 0; index < points.length; index += 1) {
const prev = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const dx = next[0] - prev[0]; const dy = next[1] - prev[1]; const length = Math.hypot(dx, dy) || 1;
const nx = -dy / length * half; const ny = dx / length * half;
left.push([points[index][0] + nx, points[index][1] + ny]);
right.unshift([points[index][0] - nx, points[index][1] - ny]);
}
return [...left, ...right, left[0]];
}
module.exports = { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };

View File

@@ -152,7 +152,10 @@ def normalize_file(input_path, tolerance):
with input_path.open("r", encoding="utf-8") as handle:
collection = json.load(handle)
for index, feature in enumerate(collection.get("features", [])):
collection["features"] = merge_custom_turn_lane_arrow_components(
collection.get("features", [])
)
for index, feature in enumerate(collection["features"]):
geometry = ogr.CreateGeometryFromJson(json.dumps(feature.get("geometry", {})))
if geometry is None or geometry.IsEmpty():
raise ValueError(f"feature {index} has no usable geometry")
@@ -171,6 +174,49 @@ def normalize_file(input_path, tolerance):
temp_path.replace(input_path)
def merge_custom_turn_lane_arrow_components(features):
"""Restore each imported SVG icon before QGIS assigns its single outline."""
grouped = {}
for feature in features:
properties = feature.get("properties", {})
arrow_id = properties.get("custom_arrow_id")
if properties.get("source") == "osm_turn_lanes" and arrow_id:
grouped.setdefault(arrow_id, []).append(feature)
merged_ids = set()
output = []
for feature in features:
properties = feature.get("properties", {})
arrow_id = properties.get("custom_arrow_id")
if properties.get("source") != "osm_turn_lanes" or not arrow_id:
output.append(feature)
continue
if arrow_id in merged_ids:
continue
merged_ids.add(arrow_id)
components = grouped[arrow_id]
multi = ogr.Geometry(ogr.wkbMultiPolygon)
for component in components:
geometry = ogr.CreateGeometryFromJson(
json.dumps(component.get("geometry", {}))
)
if geometry is None or geometry.IsEmpty() or geometry.GetGeometryName() != "POLYGON":
raise ValueError(f"custom arrow {arrow_id} has an invalid component")
multi.AddGeometry(geometry)
united = multi.UnionCascaded()
if united is None or united.IsEmpty() or united.GetGeometryName() != "POLYGON":
raise ValueError(
f"custom arrow {arrow_id} components did not form one Polygon"
)
merged = dict(components[0])
merged["properties"] = dict(components[0].get("properties", {}))
merged["properties"].pop("arrow_part", None)
merged["properties"]["component_count"] = len(components)
merged["geometry"] = json.loads(united.ExportToJson())
output.append(merged)
return output
def main():
args = cli_args()
if args.outline_simplify_meters < 0:

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { templateFor } = require("./lib/turn-lane-arrows");
const output = path.resolve(process.argv[2] || path.join("outputs", "turn-lane-arrow-samples.svg"));
const DISPLAY_SCALE = 60;
const samples = [{ id: "right", label: "right" }, { id: "through;right", label: "through;right" }];
const cells = samples.map((sample, index) => {
const x = 95 + index * 190;
const polygons = templateFor(sample.id).map((ring) => {
const points = ring.map(([right, forward]) => `${x + right * DISPLAY_SCALE},${185 - forward * DISPLAY_SCALE}`).join(" ");
return `<polygon points="${points}"/>`;
}).join("");
return `<g><rect x="${x - 70}" y="35" width="140" height="220"/><g class="arrow">${polygons}</g><text x="${x}" y="282">${sample.label}</text></g>`;
}).join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="380" height="300" viewBox="0 0 380 300"><style>rect{fill:#555650}.arrow polygon{fill:#fffff6;stroke:#2b2b28;stroke-width:1.7;stroke-linejoin:round}text{fill:#2b2b28;font:14px sans-serif;text-anchor:middle}</style>${cells}</svg>\n`;
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, svg);
console.log(`TURN_LANE_ARROW_SAMPLES ${output}`);

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
function node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
function way(id, refs, tags) { return { id, refs, tags }; }
const nodes = new Map([
[1, node(1, 0, 0)], [2, node(2, 0, 0.001)], [3, node(3, -0.001, 0.001)],
[4, node(4, 0.001, 0.001)], [5, node(5, -0.001, 0)], [6, node(6, 0.001, 0)],
]);
const primary = way(10, [1, 2], {
highway: "residential", lanes: "4", "lanes:forward": "2", "lanes:backward": "2",
"turn:lanes:forward": "through;right|left", "turn:lanes:backward": "right|through;left",
});
const osm = { nodes, ways: new Map([
[10, primary], [11, way(11, [2, 3], { highway: "residential" })], [12, way(12, [2, 4], { highway: "residential" })],
[13, way(13, [1, 5], { highway: "residential" })], [14, way(14, [1, 6], { highway: "residential" })],
]) };
const network = { intersections: [[1, { osm_ids: [1] }], [2, { osm_ids: [2] }]] };
assert.equal(normalizeManeuver("right;through"), "through;right");
assert.equal(normalizeManeuver("through;left"), "through;left");
assert.equal(normalizeManeuver("left;through;right"), "through;left;right");
assert.equal(normalizeManeuver("slight_right"), "slight_right");
const manifest = loadManifest();
assert.equal(manifest.assets.length, 14);
assert.deepEqual([...supportedAssets(manifest).keys()].sort(), ["left", "right", "through", "through;left", "through;left;right", "through;right"]);
const throughRight = templateFor("through;right", manifest);
const straightShaft = throughRight[0];
const shaftXs = straightShaft.map(([x]) => x);
assert.ok(Math.abs((Math.min(...shaftXs) + Math.max(...shaftXs)) / 2) < 1e-9, "the through shaft is anchored to the lane centerline");
const rightXs = templateFor("right", manifest).flat().map(([x]) => x);
assert.ok(Math.max(...rightXs) - Math.min(...rightXs) < 1.7, "source SVG scale stays comparable to existing lane arrows");
assert.equal(templateFor("through;left;right", manifest).length, 5, "triple maneuver has a shared straight shaft and two branches");
const disabled = buildCustomTurnLaneArrows(osm, { enabled: false, manifest });
assert.equal(disabled.features.length, 0);
assert.equal(disabled.diagnostics[0].reason, "disabled");
const result = buildCustomTurnLaneArrows(osm, { enabled: true, manifest, network });
assert.equal(result.features.length, 12, "each angular component remains a valid single Polygon feature");
assert.deepEqual([...new Set(result.features.map((feature) => feature.properties.custom_arrow_id))], [
"10:forward:0:through;right", "10:forward:1:left", "10:backward:0:right", "10:backward:1:through;left",
]);
assert.deepEqual(result.features.map((feature) => feature.properties.arrow_part), [
0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2,
]);
for (const feature of result.features) {
assert.equal(feature.geometry.type, "Polygon");
assert.equal(feature.properties.source, "osm_turn_lanes");
assert.ok(feature.properties.osm_way_id);
assert.ok(Number.isInteger(feature.properties.lane_index));
}
const forwardRight = result.features.find((feature) => feature.properties.custom_arrow_id === "10:forward:0:through;right");
const backwardRight = result.features.find((feature) => feature.properties.custom_arrow_id === "10:backward:0:right");
const centerY = (feature) => feature.geometry.coordinates[0].reduce((sum, point) => sum + point[1], 0) / feature.geometry.coordinates[0].length;
assert.ok(centerY(forwardRight) < nodes.get(2).lat, "forward arrow is placed before its endpoint");
assert.ok(centerY(backwardRight) > nodes.get(1).lat, "backward arrow is placed into its way from the endpoint");
const quadLanePolygons = [
{
type: "Feature",
properties: { type: "Driving", direction: "Fwd", index: 2, osm_way_ids: [10] },
geometry: { type: "Polygon", coordinates: [[[-0.00002, 0], [-0.00002, 0.001], [0.00002, 0.001], [0.00002, 0], [-0.00002, 0]]] },
},
{
type: "Feature",
properties: { type: "Driving", direction: "Back", index: 1, osm_way_ids: [10] },
geometry: { type: "Polygon", coordinates: [[[0.00002, 0], [0.00002, 0.001], [0.00006, 0.001], [0.00006, 0], [0.00002, 0]]] },
},
];
const centered = buildCustomTurnLaneArrows(osm, { enabled: true, manifest, network, lanePolygons: quadLanePolygons });
assert.ok(centered.features.length > 0);
assert.ok(centered.features
.filter((feature) => feature.properties.lane_index === 0)
.every((feature) => feature.properties.placement_source === "driving_lane_centerline"), "closed quadrilateral Driving lanes use their actual centerline");
const missingEndpoint = buildCustomTurnLaneArrows({ nodes, ways: new Map([[10, primary]]) }, { enabled: true, manifest });
assert.equal(missingEndpoint.features.length, 0);
assert.ok(missingEndpoint.diagnostics.every((entry) => entry.reason === "indeterminate_intersection_endpoint"));
const untested = way(20, [1, 2], { highway: "residential", "lanes:forward": "1", "turn:lanes:forward": "slight_left" });
const untestedResult = buildCustomTurnLaneArrows({ nodes, ways: new Map([[20, untested], [11, osm.ways.get(11)], [12, osm.ways.get(12)]]) }, { enabled: true, manifest });
assert.equal(untestedResult.features.length, 0);
assert.equal(untestedResult.diagnostics[0].reason, "unsupported_or_untested_maneuver");
function drivingLane(osmWayId, index, x) {
return {
type: "Feature",
properties: { type: "Driving", direction: "Fwd", index, osm_way_ids: [osmWayId] },
// The first edge ends at the junction and the opposite edge returns,
// matching osm2streets' Driving polygon winding.
geometry: { type: "Polygon", coordinates: [[
[x - 0.000006, 0.001], [x - 0.000006, -0.001],
[x + 0.000006, -0.001], [x + 0.000006, 0.001], [x - 0.000006, 0.001],
]] },
};
}
const splitWay = way(-500, [1, 2], {
highway: "residential", "lanes:forward": "2", "turn:lanes:forward": "through|through",
});
const splitOsm = { nodes, ways: new Map([[splitWay.id, splitWay]]) };
const splitResult = buildCustomTurnLaneArrows(splitOsm, {
enabled: true,
manifest,
network: { intersections: [[2, { osm_ids: [2] }]] },
// These are the pre-split rendered road IDs. Their index ordering is
// intentionally opposite to the physical left-to-right lane ordering.
lanePolygons: [drivingLane(9001, 99, -0.000015), drivingLane(9002, 1, 0.000015)],
});
assert.equal(splitResult.features.length, 2);
assert.ok(splitResult.features.every((feature) => feature.properties.placement_source === "spatial_driving_lane_centerline"));
const shaftCenter = (feature) => {
const ring = feature.geometry.coordinates[0];
return [(ring[3][0] + ring[4][0]) / 2, (ring[3][1] + ring[4][1]) / 2];
};
const splitLeft = splitResult.features.find((feature) => feature.properties.lane_index === 0);
const splitRight = splitResult.features.find((feature) => feature.properties.lane_index === 1);
assert.ok(Math.abs(shaftCenter(splitLeft)[0] + 0.000015) < 1e-9, "split-ID lane zero is centered on the physical left Driving lane");
assert.ok(Math.abs(shaftCenter(splitRight)[0] - 0.000015) < 1e-9, "split-ID lane one is centered on the physical right Driving lane");
assert.ok(shaftCenter(splitLeft)[1] < nodes.get(2).lat && shaftCenter(splitRight)[1] < nodes.get(2).lat, "spatial placement walks back along the rendered centerline from the intersection");
console.log("Turn-lane arrow tests passed.");