diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md index 7b77957..886401b 100644 --- a/.trellis/spec/config/index.md +++ b/.trellis/spec/config/index.md @@ -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`)。默认: diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md index 4e674e7..e8573bc 100644 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -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。 diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index c3d6caf..a42e71e 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -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 `。 + +### 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 diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/check.jsonl b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/check.jsonl new file mode 100644 index 0000000..e6d7e44 --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/check.jsonl @@ -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."} diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/design.md b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/design.md new file mode 100644 index 0000000..d61d87c --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/design.md @@ -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. diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.jsonl b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.jsonl new file mode 100644 index 0000000..af157da --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.md b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.md new file mode 100644 index 0000000..4a4ded1 --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/implement.md @@ -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. diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/prd.md b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/prd.md new file mode 100644 index 0000000..c32a520 --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/prd.md @@ -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` 样张的轮廓和视觉语言。 diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/research/lane-icons.md b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/research/lane-icons.md new file mode 100644 index 0000000..7b303f6 --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/research/lane-icons.md @@ -0,0 +1,40 @@ +# lane-icons research + +Source: , 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. diff --git a/.trellis/tasks/08-04-add-osm-turn-lane-arrows/task.json b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/task.json new file mode 100644 index 0000000..b799d21 --- /dev/null +++ b/.trellis/tasks/08-04-add-osm-turn-lane-arrows/task.json @@ -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": {} +} \ No newline at end of file diff --git a/assets/lane-icons/manifest.json b/assets/lane-icons/manifest.json new file mode 100644 index 0000000..c971643 --- /dev/null +++ b/assets/lane-icons/manifest.json @@ -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} + ] +} diff --git a/assets/lane-icons/upstream/DESIGN.md b/assets/lane-icons/upstream/DESIGN.md new file mode 100644 index 0000000..7aecd3d --- /dev/null +++ b/assets/lane-icons/upstream/DESIGN.md @@ -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. + +Standard Arrow + + +Arrowhead rotation angles are chosen based on the turn lane indication: + + + + + + + + + + + + + + + + +
ThroughSlight LeftLeftSharp LeftReverse Left
no rotation
+ Through +
45 degrees
+ Slight Left +
70 or 90 degrees
+ Left +
135 degrees
+ Sharp Left +
180 degrees
+ Reverse Left +
+ +As the number of arrows increases, the arrows scale down: + + + + + + + + + + + + + + +
1 Arrow2 Arrows3 Arrows4 Arrows
+
  • 3px stroke
  • standard arrowhead
+ Through +
+
  • 2.5px stroke
  • 83% scaled arrowhead
+ Left, Through +
+
  • 2.25px stroke
  • 75% scaled arrowhead
+ Left, Slight Left, Through +
+
  • 2px stroke
  • 66% scaled arrowhead
+ Reverse Left, Left, Slight Left, Through +
+ + +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). diff --git a/assets/lane-icons/upstream/LICENSE.md b/assets/lane-icons/upstream/LICENSE.md new file mode 100644 index 0000000..f3dc09b --- /dev/null +++ b/assets/lane-icons/upstream/LICENSE.md @@ -0,0 +1,118 @@ +[![CC0-1.0](http://mirrors.creativecommons.org/presskit/buttons/88x31/svg/cc-zero.svg)](http://creativecommons.org/publicdomain/zero/1.0/) + +### CC0 1.0 Universal + +#### Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +**1. Copyright and Related Rights.** A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +**2. Waiver.** To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +**3. Public License Fallback.** Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +**4. Limitations and Disclaimers.** + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + diff --git a/assets/lane-icons/upstream/left-slight_left-through.svg b/assets/lane-icons/upstream/left-slight_left-through.svg new file mode 100644 index 0000000..251770b --- /dev/null +++ b/assets/lane-icons/upstream/left-slight_left-through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/left-through.svg b/assets/lane-icons/upstream/left-through.svg new file mode 100644 index 0000000..69b75e9 --- /dev/null +++ b/assets/lane-icons/upstream/left-through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/left.svg b/assets/lane-icons/upstream/left.svg new file mode 100644 index 0000000..30005af --- /dev/null +++ b/assets/lane-icons/upstream/left.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/reverse_left-left-slight_left-through.svg b/assets/lane-icons/upstream/reverse_left-left-slight_left-through.svg new file mode 100644 index 0000000..ce8ab98 --- /dev/null +++ b/assets/lane-icons/upstream/reverse_left-left-slight_left-through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/reverse_left-through.svg b/assets/lane-icons/upstream/reverse_left-through.svg new file mode 100644 index 0000000..d0c4465 --- /dev/null +++ b/assets/lane-icons/upstream/reverse_left-through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/reverse_left.svg b/assets/lane-icons/upstream/reverse_left.svg new file mode 100644 index 0000000..7231e01 --- /dev/null +++ b/assets/lane-icons/upstream/reverse_left.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/sharp_left-through.svg b/assets/lane-icons/upstream/sharp_left-through.svg new file mode 100644 index 0000000..e8d5e43 --- /dev/null +++ b/assets/lane-icons/upstream/sharp_left-through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/sharp_left.svg b/assets/lane-icons/upstream/sharp_left.svg new file mode 100644 index 0000000..e564b70 --- /dev/null +++ b/assets/lane-icons/upstream/sharp_left.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/slight_left-through.svg b/assets/lane-icons/upstream/slight_left-through.svg new file mode 100644 index 0000000..e8e8430 --- /dev/null +++ b/assets/lane-icons/upstream/slight_left-through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/slight_left.svg b/assets/lane-icons/upstream/slight_left.svg new file mode 100644 index 0000000..19906e1 --- /dev/null +++ b/assets/lane-icons/upstream/slight_left.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/assets/lane-icons/upstream/through.svg b/assets/lane-icons/upstream/through.svg new file mode 100644 index 0000000..bbdd4f6 --- /dev/null +++ b/assets/lane-icons/upstream/through.svg @@ -0,0 +1 @@ +icons \ No newline at end of file diff --git a/config/areas/nantaizi-lake-innovation-valley.json b/config/areas/nantaizi-lake-innovation-valley.json index 96b3a9d..5f5aa11 100644 --- a/config/areas/nantaizi-lake-innovation-valley.json +++ b/config/areas/nantaizi-lake-innovation-valley.json @@ -21,6 +21,9 @@ "previewExtent": null, "layerPrefix": "osm2streets" }, + "turnLaneArrows": { + "enabled": true + }, "osm2streets": { "debug_each_step": false, "dual_carriageway_experiment": false, diff --git a/config/examples/template.json b/config/examples/template.json index 20d5a1d..006d43f 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -21,6 +21,9 @@ "previewExtent": null, "layerPrefix": "osm2streets" }, + "turnLaneArrows": { + "enabled": false + }, "osm2streets": { "debug_each_step": false, "dual_carriageway_experiment": false, diff --git a/package.json b/package.json index 0229d8d..e110ad0 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/build-area.js b/scripts/build-area.js index c0f0b3f..fba336c 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -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"); diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index c205b58..7695efe 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -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", }, diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 95adcf4..e81ecc4 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -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, diff --git a/scripts/lib/turn-lane-arrows.js b/scripts/lib/turn-lane-arrows.js new file mode 100644 index 0000000..39cfaf6 --- /dev/null +++ b/scripts/lib/turn-lane-arrows.js @@ -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(/]*)\/>|]*)\/>/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 }; diff --git a/scripts/normalize-lane-arrows.py b/scripts/normalize-lane-arrows.py index ce53f78..bdf02e6 100644 --- a/scripts/normalize-lane-arrows.py +++ b/scripts/normalize-lane-arrows.py @@ -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: diff --git a/scripts/render-turn-lane-arrow-samples.js b/scripts/render-turn-lane-arrow-samples.js new file mode 100644 index 0000000..c5fdd24 --- /dev/null +++ b/scripts/render-turn-lane-arrow-samples.js @@ -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 ``; + }).join(""); + return `${polygons}${sample.label}`; +}).join(""); +const svg = `${cells}\n`; +fs.mkdirSync(path.dirname(output), { recursive: true }); +fs.writeFileSync(output, svg); +console.log(`TURN_LANE_ARROW_SAMPLES ${output}`); diff --git a/scripts/test-turn-lane-arrows.js b/scripts/test-turn-lane-arrows.js new file mode 100644 index 0000000..07fbe90 --- /dev/null +++ b/scripts/test-turn-lane-arrows.js @@ -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.");