feat: add OSM turn lane arrows
@@ -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`)。默认:
|
||||
|
||||
@@ -83,6 +83,18 @@ cesium-preview.js 浏览器
|
||||
|
||||
## 本项目真实踩过的坑
|
||||
|
||||
### 坑 0:用原始 OSM 节点度数代替归一化路网拓扑
|
||||
|
||||
OSM way 的端点不一定在原始 XML 中有三个以上相连 way;osm2streets 可能把相邻 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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."}
|
||||
74
.trellis/tasks/08-04-add-osm-turn-lane-arrows/design.md
Normal 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.
|
||||
@@ -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."}
|
||||
39
.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.md
Normal 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.
|
||||
63
.trellis/tasks/08-04-add-osm-turn-lane-arrows/prd.md
Normal 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` 样张的轮廓和视觉语言。
|
||||
@@ -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.
|
||||
26
.trellis/tasks/08-04-add-osm-turn-lane-arrows/task.json
Normal 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": {}
|
||||
}
|
||||
24
assets/lane-icons/manifest.json
Normal 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}
|
||||
]
|
||||
}
|
||||
98
assets/lane-icons/upstream/DESIGN.md
Normal 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).
|
||||
118
assets/lane-icons/upstream/LICENSE.md
Normal file
@@ -0,0 +1,118 @@
|
||||
[](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/>
|
||||
1
assets/lane-icons/upstream/left-slight_left-through.svg
Normal 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 |
1
assets/lane-icons/upstream/left-through.svg
Normal 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 |
1
assets/lane-icons/upstream/left.svg
Normal 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 |
@@ -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 |
1
assets/lane-icons/upstream/reverse_left-through.svg
Normal 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 |
1
assets/lane-icons/upstream/reverse_left.svg
Normal 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 |
1
assets/lane-icons/upstream/sharp_left-through.svg
Normal 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 |
1
assets/lane-icons/upstream/sharp_left.svg
Normal 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 |
1
assets/lane-icons/upstream/slight_left-through.svg
Normal 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 |
1
assets/lane-icons/upstream/slight_left.svg
Normal 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 |
1
assets/lane-icons/upstream/through.svg
Normal 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 |
@@ -21,6 +21,9 @@
|
||||
"previewExtent": null,
|
||||
"layerPrefix": "osm2streets"
|
||||
},
|
||||
"turnLaneArrows": {
|
||||
"enabled": true
|
||||
},
|
||||
"osm2streets": {
|
||||
"debug_each_step": false,
|
||||
"dual_carriageway_experiment": false,
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"previewExtent": null,
|
||||
"layerPrefix": "osm2streets"
|
||||
},
|
||||
"turnLaneArrows": {
|
||||
"enabled": false
|
||||
},
|
||||
"osm2streets": {
|
||||
"debug_each_step": false,
|
||||
"dual_carriageway_experiment": false,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
506
scripts/lib/turn-lane-arrows.js
Normal 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 };
|
||||
@@ -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:
|
||||
|
||||
22
scripts/render-turn-lane-arrow-samples.js
Normal 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}`);
|
||||
121
scripts/test-turn-lane-arrows.js
Normal 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.");
|
||||