feat: parameterize complex junction geometry with Gaode reference

- 高德 GeoJSON 参考流程: `scripts/lib/gaode-junction-reference.js`
  与 `scripts/inspect-junction-reference.js` 将 GCJ-02 参考转换为 WGS84,
  按 node id/最近距离关联 OSM, 支持普通路口面和 `complex-cluster` 两种匹配。
- 复合路口模板 `complex-junction-v1`: `scripts/lib/complex-junction.js` 用参考
  几何校准 core 半径, 生成路口面、进口路面、斑马线、停止线、角部圆角与安全岛;
  拓扑/信号/连接全部沿用 OSM/native。
- 车道中心线控制要素避让: `compileLaneCenterlines` 现接收模板已产出的斑马线/停止线,
  新增 `trimLaneOutsideControls` 按到路口中心的半径定向裁剪; 标线源几何同步裁剪, 不再
  越过斑马线继续画到核心区。拓扑几何不变, connector 集合前后一致。
- 复合路口人行道转角: `buildComplexJunctionGeometry` 沿已定义的路缘生成 2m 宽转角带,
  复用圆角曲线, 通过 `islands` 通道并入 `sidewalk_surface`; 自交或坐标非有限时报
  `complex-junction-sidewalk-corner-fallback` 并跳过。
- 新增诊断: `complex-junction-configured-radius-ignored`、
  `lane-centerline-fully-inside-control`、`complex-junction-sidewalk-corner-fallback`。
- 死码清理: 移除未被调用的 `clusterApproachRing`。
- spec 更新: `.trellis/spec/pipeline/cli-and-stages.md` 复合路口小节补充控制要素
  避让顺序、人行道转角契约、Validation 矩阵三行; 索引新增导航。
- 任务产物 `08-19-gaode-junction-reference`: 8 条验收标准全部实测记录,
  Scope Drift / Verification Log / Known Gaps 三节沉淀本次工作。

Regression: test:native-road / test:road-workbench / test:preflight /
test:native-preview-traffic / test:package-contract / test:traffic-signals /
test:gaode-junction-reference 全绿; road:check ok=true, errors=[]。
This commit is contained in:
2026-08-21 11:59:03 +08:00
parent 9a8dbc1a74
commit 12aeda9a63
31 changed files with 2134 additions and 95 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ outputs/
__pycache__/ __pycache__/
assets/models/speedtree/ assets/models/speedtree/
assets/models/lyrog/ assets/models/lyrog/
inputs/osm

View File

@@ -54,6 +54,7 @@ cp config/examples/template.json config/areas/my-area.json
| `turnLaneArrows` | | 见下 | 从 OSM `turn:lanes:*` 生成自定义车道箭头的发布开关 | | `turnLaneArrows` | | 见下 | 从 OSM `turn:lanes:*` 生成自定义车道箭头的发布开关 |
| `osm2streets` | | 见下 | 透传给 osm2streets 的选项 | | `osm2streets` | | 见下 | 透传给 osm2streets 的选项 |
| `blender` | | 见下 | Blender 侧选项 | | `blender` | | 见下 | Blender 侧选项 |
| `nativeRoad` | | 见下 | 原生道路编译选项 |
| `compress` | | 见下 | 默认交付压缩阶段的 GLB 压缩选项 | | `compress` | | 见下 | 默认交付压缩阶段的 GLB 压缩选项 |
| `budget` | | 见下 | 区域 GLB 性能与体量预算 | | `budget` | | 见下 | 区域 GLB 性能与体量预算 |
| `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 | | `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 |
@@ -134,6 +135,17 @@ cp config/examples/template.json config/areas/my-area.json
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 | | `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
| `roadProvider` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 | | `roadProvider` | `"native"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;`"osm2streets"` 仅用于显式 legacy/debug 构建。 |
### `nativeRoad`
| 字段 | 默认 | 说明 |
|---|---|---|
| `edgeLines` | `false` | 是否输出道路边缘线。 |
| `junctionTemplates.enabled` | `false` | 启用显式绑定的参数化路口模板。 |
| `junctionTemplates.references` | `[]` | 仅支持 `cross-v1`;每项必须给出 OSM `nodeId`,可带 GCJ-02 `referenceFile` 作校准与有效性检查。 |
| `junctionTemplates.clusters` | `[]` | `cross-cluster-v1` 的相邻 OSM 节点簇。输出各外部进口的参数化渐变道路面,簇内短段与节点级路口面保留;`approachWidthMultiplier` / `approachLengthMeters` 控制渐变。可选 `referenceFile` 记录 GCJ-02 校准来源不合并节点级道路、connector、信号或停止线语义。 |
| `junctionTemplates.clusters[].cornerRadiusMeters` | `12`425 | 仅 `complex-junction-v1`:相邻进口夹角处路缘圆角的半径。圆角切于两侧最外道路边缘,只补齐夹角处的路面,不改变 connector、信号或停止线。 |
| `junctionTemplates.clusters[].outerRadiusExtraMeters` | `18`1835 | 仅 `complex-junction-v1`:路口中心到外部进口交接边界的额外半径。用于让圆角包住角部斑马线;未配置时保持原有 18 m。 |
### `compress` ### `compress`
完整构建和显式 `--stages compress` 都使用此配置。默认压缩链是 texture resize + WebP 完整构建和显式 `--stages compress` 都使用此配置。默认压缩链是 texture resize + WebP

View File

@@ -815,6 +815,32 @@ area.outputs.trafficSignals
- `layout.mastHeightMeters``layout.headCenterHeightMeters` 必须相等,表示横杆与灯壳的 - `layout.mastHeightMeters``layout.headCenterHeightMeters` 必须相等,表示横杆与灯壳的
中心对齐;`lensVerticalOffsetsMeters` 以灯壳中心为基准,正值向上、负值向下。当前倒计时牌 中心对齐;`lensVerticalOffsetsMeters` 以灯壳中心为基准,正值向上、负值向下。当前倒计时牌
垂直偏移为 `0`,必须贴在横杆上而非悬挂。 垂直偏移为 `0`,必须贴在横杆上而非悬挂。
- 复合路口中,具有相同 `cluster_id` 的停止线按道路轴线匹配 OSM control arm轴线的两个
极性必须视为等价,但每条候选停止线仍只能消费一次。普通路口继续使用有向进口匹配,不能
用复合路口的无向规则覆盖普通路口。
- 复合路口的中心绿化带/中央分隔带不由 native 复合模板伪造;模板只生成 OSM 道路和路口
内必要的安全岛。每条生成的复合进口车道须在停止线外侧上游约 `8m` 生成一支与车道拓扑
一致的左转、直行或右转箭头;不得额外叠加一套固定直行箭头。
- **控制要素避让顺序**`buildComplexJunctionGeometry``compileLaneCenterlines` 之前生成,
产出的 `complex-crosswalk` / `complex-corner-crosswalk` / `complex-stop-line` 必须以
`{ crosswalks, stopLines }` 参数传入 `compileLaneCenterlines`。车道中心线、车道分隔线、
中心虚线、导向箭头都在这一裁剪之后才落地。这里的裁剪对象包括两类车道几何:一是簇
边界上的 OSM 车道,二是模板自造的 `cluster-approach-lane` 预览车道。裁剪必须先按到
路口中心的距离把线定向为“外向内”,再复用 `trimLineBeforeFirstControl`:因为 OSM 车道
由簇边缘向外指、预览车道由外圈向核心指,两种朝向都存在,直接裁会裁反。裁剪后的几何
同时喂给车道标线源(`markingLanes[i].coordinates`),否则分隔线/箭头会画到斑马线之外
继续侵入核心区。拓扑几何 `coordinates`(供 connector 使用)不得裁剪,否则 turn path
会断。发布几何 vs 拓扑几何的双轨在 `compileLaneCenterlines` 内并存,别合并。
- **复合路口人行道转角**:直步行带被 `trimLineAtComplexCluster` 裁在簇外圈,两条相邻的
臂之间会留一个楔形无覆盖区。转角带由 `buildComplexJunctionGeometry``complex-corner-fillet`
共用同一 `curve` / `tangents` / `edges` / `apex` 复用生成,不重新拟合曲线;宽度
`SIDEWALK_WIDTH_METERS = 2` 必须与 `native-road.js:DEFAULT_SIDEWALK_WIDTH_METERS` 保持
一致,法线方向按“离簇中心更远”选取(路缘相对中心是星形的)。判定两侧臂是否都带步行带
时必须按臂的外向方向翻转 road 自身的 `sidewalkLeft/Right`,因为那是相对数字化方向的。
端头以 `SIDEWALK_CORNER_OVERRUN_METERS = 6` 冗余越过簇边界,让转角带和直条带自然重叠,
不追求精确缝合。自交(`ringSelfIntersects`)或任一坐标非有限时跳过并报
`complex-junction-sidewalk-corner-fallback`;不允许硬塞坏几何。产物经 `islands` 通道
与安全岛一同并入 `generatedComplexSidewalks`,最终落入 `sidewalk_surface.geojson`
### 4. Validation & Error Matrix ### 4. Validation & Error Matrix
@@ -824,6 +850,9 @@ area.outputs.trafficSignals
| 直接运行 `blender` / `preview` 但锚点不存在 | 在启动外部工具前报 `Traffic signal anchors not found` | | 直接运行 `blender` / `preview` 但锚点不存在 | 在启动外部工具前报 `Traffic signal anchors not found` |
| `signal_uid` 缺失/重复、非空 `display_id` 重复、字段或 Point 无效 | 重导入在替换任何输出前失败 | | `signal_uid` 缺失/重复、非空 `display_id` 重复、字段或 Point 无效 | 重导入在替换任何输出前失败 |
| 用户仅修改 QGIS 后运行 `reimport` | 重新生成锚点,不沿用旧坐标 | | 用户仅修改 QGIS 后运行 `reimport` | 重新生成锚点,不沿用旧坐标 |
| 复合路口车道整体落在斑马线/停止线内 | 报 `lane-centerline-fully-inside-control`,回退为未裁剪发布几何,不写空 LineString |
| 复合路口人行道转角自交或坐标非有限 | 报 `complex-junction-sidewalk-corner-fallback`,跳过该角,两侧步行带保持断开 |
| 有 `referenceFile` 时同时配置 `coreRadiusMeters` | 校准值胜出,报 `complex-junction-configured-radius-ignored` 记录被忽略的配置值 |
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases

View File

@@ -15,6 +15,7 @@
| 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) | | 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) |
| 改区域诊断/质量门命令或共享区域配置归一化 | [CLI 与阶段](./cli-and-stages.md#区域诊断命令) 和 [质量门](./cli-and-stages.md#区域质量门命令) | | 改区域诊断/质量门命令或共享区域配置归一化 | [CLI 与阶段](./cli-and-stages.md#区域诊断命令) 和 [质量门](./cli-and-stages.md#区域质量门命令) |
| 改 stage manifest 写入、读取或 stale 判断 | [CLI 与阶段](./cli-and-stages.md#stage-manifest-契约) | | 改 stage manifest 写入、读取或 stale 判断 | [CLI 与阶段](./cli-and-stages.md#stage-manifest-契约) |
| 改复合路口几何(`buildComplexJunctionGeometry`、车道控制避让、人行道转角) | [CLI 与阶段](./cli-and-stages.md#可编辑信号设施与运行时锚点的跨阶段消费) 的复合路口小节 |
| 改预览页生成 | [../preview/](../preview/index.md) | | 改预览页生成 | [../preview/](../preview/index.md) |
| 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) | | 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) |

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,57 @@
# Technical Design
## Boundary
The native compiler remains authoritative for OSM road identity, directed lanes,
connections, traffic signals, stop lines, and preview routes. A reference layer is
an optional geometry-calibration input consumed before junction surface publication.
## Proposed Flow
```text
optional reference GeoJSON (GCJ-02)
-> validate schema and coordinate bounds
-> GCJ-02 to WGS84 conversion
-> associate reference footprint with an OSM junction node
-> classify geometry by observable shape, not opaque vendor type alone
-> derive bounded junction-shape parameters
-> native compiler creates surface/connectors as usual
-> diagnostics compare generated geometry with reference
```
The first production validation keeps the reference overlay/diagnostic, then enables a
bounded `cross` junction template for explicitly configured references. Connector
endpoints and movement enumeration still come from the native model. T and complex
junctions remain on the existing algorithm until their templates are separately validated.
## Input Contract
Use an optional area-level reference configuration rather than a hard-coded filename.
The sample points to `inputs/osm/珠山湖大道(枫树二路)口.geojson`, declares `GCJ-02`,
and identifies the target through OSM node `8005332807` or a stable spatial/name match.
The file is not assumed to contain a CRS member; the configured source CRS is required.
## Geometry Safety
- Convert all reference coordinates to WGS84 before distance/overlap tests.
- Reject malformed or non-finite coordinates and references outside the OSM bounds plus a
bounded tolerance.
- Never let a reference polygon create a movement, lane, signal, or stop-line record.
- Keep a fallback path to the current `compileGeometry()` junction algorithm.
## Compatibility And Rollback
No-reference configs and existing output schemas remain unchanged. The experiment can be
disabled by omitting the reference section or setting it disabled. Generated diagnostics
must record source file hash, conversion mode, matched OSM node, and whether fallback was used.
No source reference file is rewritten.
## Open Technical Work
1. Select the smallest reusable geometry parameter set from the sample (approach cutback,
corner radius/rounding, island/marking envelopes, and tolerances).
2. Implement and test GCJ-02 conversion independently from compiler logic.
3. Compare the sample against all native junctions near the converted footprint before any
production surface override is enabled.
4. Add topology classification and template dispatch; only cross is enabled in this validation.
5. Preserve native connectors and controls while replacing only the bounded junction surface geometry.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,53 @@
# Validation Implementation Plan
进度标记于 2026-08-21。
1. [x] Add a read-only reference inspection utility or library helper that validates GeoJSON,
converts GCJ-02 to WGS84, computes bounds/center, and associates the sample with OSM node
`8005332807`. — `scripts/lib/gaode-junction-reference.js` + `scripts/inspect-junction-reference.js`
2. [x] Add focused tests for coordinate conversion, malformed input rejection, spatial association,
and the no-reference fallback. — `npm run test:gaode-junction-reference`
3. [x] Add a diagnostic comparison between the reference footprint and native
`intersection_surface.geojson`, including center offset, envelope overlap, and approach headings.
— 复杂路口的面写在 `road_surface.geojson``complex_part`)而非 `intersection_surface`
原实现匹配不到、恒返回 `nativeIntersection: null`。已扩展为可回落到复杂路口簇
(新增 `--native-road-surface` / `--cluster-id`schema 升至 v2
4. [x] Add a reference-backed, parameterized junction template and dispatch it only for
explicitly enabled samples. — 实际实现为 `complex-junction-v1`,非原计划的 cross 模板,
范围变更见 prd.md「Scope Drift」。
5. [x] Preserve connector, movement, signal, and stop-line generation; validate containment against
the new surface. — A/B 实测通过,数据见 prd.md「Verification Log」。
6. [x] Run native-road, preflight, preview-traffic, and package-contract tests, plus the sample
comparison. Inspect the generated overlay/diagnostic manually.
— 2026-08-21 全绿:`test:gaode-junction-reference``test:native-road``test:road-workbench`
`test:preflight``test:native-preview-traffic``test:package-contract``test:traffic-signals`
外加 `road:check``ok: true, errors: []`。人工视觉确认仍待用户。
7. [x] Stop at validation. Do not commit,
and do not modify the source OSM or reference GeoJSON. — 未 commit参考 GeoJSON 未改;
源 OSM 有改动但来自人工 JOSM 编辑,非管线写入。
## 本次会话追加的修复2026-08-21
- 车道中心线与车道标线越过斑马线:`compileLaneCenterlines` 现接收模板已产出的斑马线/停止线,
新增 `trimLaneOutsideControls`(按到路口中心的半径定向后再裁剪,因为车道几何两种朝向都存在)。
拓扑几何 `coordinates` 保持完整,仅裁剪发布几何与标线源几何。
实测越线要素 20 → 0分隔线越界 2 → 0connector 356 / movement 370 前后一致。
- 新增诊断 `complex-junction-configured-radius-ignored``lane-centerline-fully-inside-control`
- 删除死函数 `clusterApproachRing`
- **复杂路口人行道转角**`complex-junction.js`):直条带被裁在簇边界后,相邻两臂的步行带
各自留一个断头,中间整个楔形无覆盖。现沿圆角已定义的路缘补一条 2 m 宽转角带。
- 复用同一函数作用域内已算好的 `curve` / `tangents` / `edges` / `apex`,不重新拟合曲线
- 新增 `armCarriesSidewalk`(按臂的外向方向翻转道路自身的 `sidewalkLeft/Right`)、
`edgeRunToRadius``offsetPolylineAwayFromCenter``ringSelfIntersects`
-`islands` 通道并入 `generatedComplexSidewalks`
- 自交或几何无效时跳过并报 `complex-junction-sidewalk-corner-fallback`,不硬塞坏几何
- 实测:人行道要素 7 → 8生成角 1平分线 286°241°→331°57 顶点);无自交;
与两侧条带均相接;面积采样 331 点中 0 点落在车行道内
- **限制**:全区 139 条路仅 3 条带人行道4 个角中只有 286° 两侧齐备,
其余 3 个角当前不产出,缺少验证样本
## Rollback Points
- Delete/disable the optional reference configuration to restore the default native path.
- Remove only the reference diagnostic output; existing native-road outputs remain authoritative.
- Do not overwrite existing layers until the comparison is accepted.

View File

@@ -0,0 +1,110 @@
# Parameterize junction geometry with optional Gaode reference
## Goal
验证一种通用的、参数化的路口几何生成范式:借鉴高德路口 GeoJSON 的规整边界和标线组织方式,
但保持 OSM/native compiler 对道路身份、连接关系、车道可行驶性、信号和停止线的权威性。
当前阶段先做一个可回滚的生产几何验证,不提交代码或产物。
## Requirements
- R1: native 路口在没有参考数据时行为不变,现有 OSM/native 输出仍是默认路径。
- R2: 支持可选的路口参考输入至少记录输入路径、坐标来源GCJ-02和目标 OSM 路口关联方式。
- R3: 参考输入先转换为 WGS84禁止将 GCJ-02 坐标直接与 OSM/native 几何叠加。
- R4: 参考数据只允许影响路口面的规整化参数、边界形态和标线布局;不得改变 OSM 道路、车道、connector、
signal_uid、停止线归属或车辆路线拓扑。
- R5: 不机械复制高德 feature 的 `type=1..5`;先建立可审计的几何类别映射或只使用其可验证的形态特征。
- R6: 以“珠山湖大道(枫树二路)”路口作为验证样本,输出参考前后对比、偏差诊断和可回滚结果。
- R6.1: 生成器按路口拓扑选择模板,至少区分 cross、T 和复杂/多臂路口;不能用一个固定几何参数机械覆盖所有路口。
- R6.2: 首个生产几何验证只启用 cross 模板,利用参考样本校准 cutback、圆角和进口过渡T/复杂路口先保留现有 native 行为并记录诊断。
- R6.3: 模板生成的路口面必须由 OSM/native 道路宽度、方向和车道结构驱动;高德几何只用于校准参数或对比,不直接复制其 feature。
- R7: 参考文件缺失、坐标转换失败、无法关联 OSM 路口或几何越界时,构建必须回退默认 native 算法并给出诊断,
不得静默覆盖生产几何。
- R8: 保持当前阶段 manifest、GLB/package 契约和无参考区域的 parity 行为不变。
## Acceptance Criteria
> 措辞修订说明见文末「Scope Drift」。勾选项均为 2026-08-21 实测证据记录在「Verification Log」。
- [x] 不配置参考输入时,现有 native-road 测试和样本区域输出保持默认行为。
- [x] 配置枫树二路参考后,能稳定关联 OSM node `8005332807`,并生成 WGS84 参考诊断。
- [x] 参考几何与 native 路口的叠加结果可检查:路口中心、边界、进口方向有数值和 SVG 记录。
- [x] 参考模式不改变 OSM 衍生的 connector、车道连接、信号或停止线集合模板只新增自有
`complex-*` 命名空间要素。(原措辞「不会新增或删除…停止线」严于 R4 本意,已修订)
- [x] 参考模式失败时有明确 warning并成功回退默认 native 几何。
- [x] 模板生成的路口面视觉上接近高德参考。connector / 信号 / 停止线集合一致性已实测通过;
视觉接近度由用户 2026-08-21 在工作台确认(「基本 ok」其间提出的人行道转角缺口已修复并复核通过。
- [x] 无参考路口继续走默认 native 几何,不被模板误套。
- [x] 验证产物写入区域输出目录,未执行 git commit。源 OSM 有改动,但来自人工 JOSM 编辑,
非管线写入;原措辞未区分这两者,已修订)
## Scope Drift2026-08-21 记录)
R6.2 原定「首个生产几何验证只启用 cross 模板T/复杂路口先保留现有 native 行为」。
实际实现走的是 `complex-junction-v1`,对枫树二路 4 节点簇做整体路口面、斑马线、停止线、
角部安全岛和圆角生成。范围已越过 R6.2本文档据实修订R6.2 视为被本次决策取代。
cross 模板(`cross-v1` / `cross-cluster-v1`)代码路径仍在,未被删除。
## Verification Log2026-08-21
参考模式 A/B 对照(同一 OSM 输入,`junctionTemplates.enabled` 开/关,独立 outputRoot
| 指标 | ON | OFF | 判定 |
| --- | --- | --- | --- |
| connector 要素 | 356 | 356 | 集合逐一相同,无新增/缺失 |
| movement / published | 370 / 356 | 370 / 356 | 一致 |
| connection | 215 | 215 | 一致 |
| 信号 uid 集合 | 20 | 20 | 集合完全一致 |
| 普通斑马线 `native-road-crosswalk/v1` | 6 | 6 | 一致 |
| 模板新增 `complex-crosswalk` | 128 | 0 | 模板自有命名空间 |
| 模板新增 `complex-corner-crosswalk` | 24 | 0 | 同上 |
| 模板新增 `complex-stop-line` | 4 | 0 | 同上 |
| 预览车道 `cluster-approach-lane` | 24 | 0 | 同上 |
参考关联与叠加(`npm run reference:junction`
- 匹配方式 `node-id`,命中 `8005332807`,参考中心到节点 **8.98 m**
- 匹配到 `complex-cluster` = `zhushanhu-fengshu-complex`13 个要素
- native core 半径 **24 m**(配置 `coreRadiusMeters: 28`),进口 4 / 车行道 8
进口朝向 `[-118.5, -28, 62.2, 151.9]`
- 已知口径差异:检查工具用参考文件**全部** feature 算 bounds约 380×330 m含长引道
`readReferenceCalibration` 只取 `type ∈ [1,2,3,4]` 的子集。两者不同量级,
故 bbox IoU0.070)当前**不是**可用的质量指标,仅中心偏差和朝向可比。
失败回退(`referenceFile` 指向不存在路径):
- 编译成功不抛错;诊断由 `complex-junction-reference-calibrated` 降级为 `complex-junction-generated`
- connector 356 / movement 370 不变
- 该路径下 `lane-centerline-fully-inside-control` 触发 10 次:无参考时 coreRadius 仅 14.56 m
10 条车道整体落在控制带内,回退为未裁剪发布。非回归(改动前同样未裁剪),但属已知限制。
非簇路口未受模板影响A/B `intersection_surface`
| | ON | OFF |
| --- | --- | --- |
| 普通路口面总数 | 41 | 45 |
| 簇成员节点出现次数 | 0预期 0 | 4模板关闭时预期 4 |
| 非簇节点41 个) | — | 与 ON 逐点几何完全一致,无增无缺 |
| 模板 `complex_part` 要素 | 13全部归属唯一簇 `zhushanhu-fengshu-complex` | 0 |
结论:模板严格限定在 `nodeIds` 列表内,无外溢。
## Known Gaps2026-08-21
1. `way/858770823:segment/2`(长 19.2 m远端为 service 路口 `8613058676`)被 `native-road.js`
的 cluster 丢弃判据误伤,整段无路面,该节点周边 25×25 m 内 25.8% 露底。
判据应从「远端在 outerRadius 内」改为「远端也是 cluster 成员节点」,实测该判据分界干净。
**用户 2026-08-21 明确暂缓,未修改。**
2. `coreRadiusMeters` 在有参考文件时不参与计算(被 calibration 覆盖并 clamp 到 24
已加诊断 `complex-junction-configured-radius-ignored` 使其可见,行为未改。
3. `approachLengthMeters``complex-junction-v1` 无效(仅 `cross-v1` / `cross-cluster-v1` 使用)。
死函数 `clusterApproachRing` 已删除。
4. 无参考路径下普通停止线为 0`crossing-no-safe-stop-line` 告警 1 条),先于本任务存在。
## Constraints And Deferred Scope
- OSM WGS84 与 native 拓扑是交通语义的单一事实源。
- 高德参考暂不作为完整道路网络输入,也不直接替代 road surface 或 connector 几何。
- 暂不定义所有高德 `type` 值的业务语义;先验证几何范式和校准边界。
- 暂不提交代码、配置或生成产物;本阶段完成后由用户决定是否进入正式实现。

View File

@@ -0,0 +1,26 @@
{
"id": "gaode-junction-reference",
"name": "gaode-junction-reference",
"title": "Parameterize junction geometry with optional Gaode reference",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-19",
"completedAt": null,
"branch": null,
"base_branch": "fengshu-er-road",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -55,6 +55,20 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json
## 导出为 Cesium GLB ## 导出为 Cesium GLB
导出脚本会优先使用 `tqdm` 显示固定在终端底部的进度条、速率和 ETA。它是可选依赖未安装
时脚本自动回退为普通的 `CESIUM_EXPORT_PROGRESS` 日志,不会阻止导出。
macOS Blender 可用自带 Python 安装依赖:
```bash
/Applications/Blender.app/Contents/Resources/4.5/python/bin/python3.11 \
-m pip install -r blender/requirements.txt
```
若 Blender 安装路径或版本不同,使用对应的 `Resources/<version>/python/bin/python3.*`
安装后必须使用同一 Blender 安装启动导出脚本,系统 Python 中安装的 `tqdm` 不会自动被
Blender 发现。
```bash ```bash
/Applications/Blender.app/Contents/MacOS/Blender \ /Applications/Blender.app/Contents/MacOS/Blender \
--background \ --background \

View File

@@ -16,6 +16,11 @@ import sys
import bpy import bpy
import numpy as np import numpy as np
try:
from tqdm import tqdm
except ImportError:
tqdm = None
# --factory-startup does not put the script's own directory on sys.path, so the # --factory-startup does not put the script's own directory on sys.path, so the
# osmassets package next to this file is not importable without this. # osmassets package next to this file is not importable without this.
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
@@ -603,9 +608,39 @@ def semantic_asset_specs(glb_path, meshes):
return specs return specs
class ExportProgress:
"""Use tqdm when available, while keeping Blender builds dependency-free."""
def __init__(self):
self.percent = 0
self.bar = tqdm(total=100, desc="Cesium export", unit="%",
dynamic_ncols=True, mininterval=0.2,
disable=tqdm is None) if tqdm else None
def update(self, stage, percent, detail=""):
percent = max(self.percent, min(100, float(percent)))
delta = percent - self.percent
self.percent = percent
if self.bar:
self.bar.set_description("Cesium export | %s" % stage)
self.bar.set_postfix_str(detail, refresh=False)
if delta:
self.bar.update(delta)
self.bar.refresh()
return
suffix = " - " + detail if detail else ""
print("CESIUM_EXPORT_PROGRESS %3.0f%% [%s]%s" % (percent, stage, suffix), flush=True)
def close(self):
if self.bar:
self.bar.close()
def export(args): def export(args):
if not os.path.exists(args["blend"]): if not os.path.exists(args["blend"]):
raise FileNotFoundError(args["blend"]) raise FileNotFoundError(args["blend"])
progress = ExportProgress()
progress.update("load scene", 1, os.path.basename(args["blend"]))
bpy.ops.wm.open_mainfile(filepath=args["blend"]) bpy.ops.wm.open_mainfile(filepath=args["blend"])
material_map = {} material_map = {}
@@ -613,13 +648,13 @@ def export(args):
dynamic_meshes = [] dynamic_meshes = []
countdown_meshes = {0: [], 1: []} countdown_meshes = {0: [], 1: []}
unwrapped = set() unwrapped = set()
for obj in bpy.context.scene.objects: scene_objects = list(bpy.context.scene.objects)
if obj.type != "MESH": candidates = [obj for obj in scene_objects
continue if obj.type == "MESH" and obj.name != "Ground Plane"
if obj.name == "Ground Plane": and not obj.hide_viewport and not obj.hide_render]
continue total_candidates = max(1, len(candidates))
if obj.hide_viewport or obj.hide_render: progress.update("prepare scene", 3, "%d mesh objects" % len(candidates))
continue for index, obj in enumerate(candidates, 1):
if any(c.name == "06_TrafficSignalsDynamic" for c in obj.users_collection): if any(c.name == "06_TrafficSignalsDynamic" for c in obj.users_collection):
groups = {slot.material.name for slot in obj.material_slots if slot.material} groups = {slot.material.name for slot in obj.material_slots if slot.material}
group = (0 if any("Countdown Group 0" in name for name in groups) group = (0 if any("Countdown Group 0" in name for name in groups)
@@ -650,21 +685,36 @@ def export(args):
if source.name not in material_map: if source.name not in material_map:
material_map[source.name] = make_export_material(source) material_map[source.name] = make_export_material(source)
slot.material = material_map[source.name] slot.material = material_map[source.name]
if index == total_candidates or index % max(1, total_candidates // 50) == 0:
progress.update("prepare scene", 3 + 42 * index / total_candidates,
"%d/%d objects, %d materials" % (index, total_candidates, len(material_map)))
export_glb(args["glb"], meshes) semantic_assets = semantic_asset_specs(args["glb"], meshes)
exports = [(args["glb"], meshes, "main scene")]
if args.get("dynamic_glb"): if args.get("dynamic_glb"):
if not dynamic_meshes: if not dynamic_meshes:
raise RuntimeError("Dynamic traffic signal collection is empty") raise RuntimeError("Dynamic traffic signal collection is empty")
export_glb(args["dynamic_glb"], dynamic_meshes) exports.append((args["dynamic_glb"], dynamic_meshes, "dynamic signals"))
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")): for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
if not args.get(key): if not args.get(key):
continue continue
if not countdown_meshes[group]: if not countdown_meshes[group]:
raise RuntimeError("Traffic countdown collection %d is empty" % group) raise RuntimeError("Traffic countdown collection %d is empty" % group)
export_glb(args[key], countdown_meshes[group]) exports.append((args[key], countdown_meshes[group], "countdown group %d" % group))
semantic_assets = semantic_asset_specs(args["glb"], meshes)
for asset in semantic_assets: for asset in semantic_assets:
export_glb(asset["path"], asset["meshes"]) exports.append((asset["path"], asset["meshes"], "%s layer" % asset["id"]))
total_export_objects = max(1, sum(len(objects) for _, objects, _ in exports))
completed_export_objects = 0
for filepath, objects, label in exports:
start_percent = 45 + 50 * completed_export_objects / total_export_objects
end_percent = 45 + 50 * (completed_export_objects + len(objects)) / total_export_objects
progress.update("export GLB", start_percent,
"%s (%d objects)" % (label, len(objects)))
export_glb(filepath, objects)
completed_export_objects += len(objects)
progress.update("export GLB", end_percent, "%s complete" % label)
progress.update("write metadata", 97)
scene = bpy.context.scene scene = bpy.context.scene
try: try:
@@ -711,6 +761,8 @@ def export(args):
json.dump(metadata, handle, ensure_ascii=False, indent=2) json.dump(metadata, handle, ensure_ascii=False, indent=2)
handle.write("\n") handle.write("\n")
progress.update("complete", 100, "all assets exported")
progress.close()
print("CESIUM_EXPORT_DONE", json.dumps({ print("CESIUM_EXPORT_DONE", json.dumps({
"glb": args["glb"], "metadata": args["metadata"], "glb": args["glb"], "metadata": args["metadata"],
"meshes": len(meshes), "materials": len(material_map), "meshes": len(meshes), "materials": len(material_map),

2
blender/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
# Optional runtime helper for export_cesium.py progress display.
tqdm>=4.66,<5

View File

@@ -25,7 +25,24 @@
"enabled": false "enabled": false
}, },
"nativeRoad": { "nativeRoad": {
"edgeLines": false "edgeLines": false,
"junctionTemplates": {
"enabled": true,
"references": [],
"clusters": [
{
"id": "zhushanhu-fengshu-complex",
"template": "complex-junction-v1",
"referenceFile": "/Users/que01/osm2streets-qgis-workflow/inputs/osm/珠山湖大道(枫树二路)口.geojson",
"approachWidthMultiplier": 1.45,
"approachLengthMeters": 32,
"coreRadiusMeters": 28,
"cornerRadiusMeters": 25,
"outerRadiusExtraMeters": 24,
"nodeIds": ["8005332807", "8024512135", "8024512145", "8024512147"]
}
]
}
}, },
"osm2streets": { "osm2streets": {
"debug_each_step": false, "debug_each_step": false,

View File

@@ -25,7 +25,12 @@
"enabled": false "enabled": false
}, },
"nativeRoad": { "nativeRoad": {
"edgeLines": false "edgeLines": false,
"junctionTemplates": {
"enabled": false,
"references": [],
"clusters": []
}
}, },
"osm2streets": { "osm2streets": {
"debug_each_step": false, "debug_each_step": false,

View File

@@ -12,10 +12,12 @@
"diagnose:area": "node scripts/diagnose-area.js", "diagnose:area": "node scripts/diagnose-area.js",
"preflight:area": "node scripts/preflight-area.js", "preflight:area": "node scripts/preflight-area.js",
"road:compile": "node scripts/compile-native-roads.js", "road:compile": "node scripts/compile-native-roads.js",
"reference:junction": "node scripts/inspect-junction-reference.js",
"road:check": "node scripts/check-native-roads.js", "road:check": "node scripts/check-native-roads.js",
"road:workbench": "node scripts/road-workbench.js", "road:workbench": "node scripts/road-workbench.js",
"test:road-workbench": "node scripts/test-road-workbench.js", "test:road-workbench": "node scripts/test-road-workbench.js",
"test:native-road": "node scripts/test-native-road.js", "test:native-road": "node scripts/test-native-road.js",
"test:gaode-junction-reference": "node scripts/test-gaode-junction-reference.js",
"test:preflight": "node scripts/test-area-preflight.js", "test:preflight": "node scripts/test-area-preflight.js",
"test:build-stages": "node scripts/test-build-stages.js", "test:build-stages": "node scripts/test-build-stages.js",
"test:budgets": "node scripts/test-asset-budgets.js", "test:budgets": "node scripts/test-asset-budgets.js",

View File

@@ -25,7 +25,7 @@ function compileArea(configPath) {
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
validateOverrides(overrides, model); validateOverrides(overrides, model);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines }); const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates });
const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface); const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface);
const signalRuntime = runtime(signalDocument); const signalRuntime = runtime(signalDocument);
// Persist validation normalization, including one-time legacy heading migration. // Persist validation normalization, including one-time legacy heading migration.

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { inspectReference, localReferenceSvg } = require("./lib/gaode-junction-reference");
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 1) {
if (!argv[index].startsWith("--")) continue;
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : "true";
}
return result;
}
function required(args, key) {
if (!args[key]) throw new Error(`--${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)} is required`);
return path.resolve(args[key]);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const result = inspectReference({
referenceFile: required(args, "reference"),
osmFile: required(args, "osm"),
nativeIntersectionFile: required(args, "nativeIntersection"),
nativeRoadSurfaceFile: args.nativeRoadSurface ? path.resolve(args.nativeRoadSurface) : null,
nodeId: args.nodeId,
clusterId: args.clusterId,
});
const output = path.resolve(args.output || `${args.reference.replace(/\.geojson$/i, "")}-wgs84.geojson`);
const comparison = output.replace(/\.geojson$/i, "-comparison.json");
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, `${JSON.stringify(result.converted, null, 2)}\n`);
fs.writeFileSync(comparison, `${JSON.stringify({ ...result, converted: undefined, matchedFeatures: undefined }, null, 2)}\n`);
const svg = output.replace(/\.geojson$/i, "-overlay.svg");
// Draw whatever the comparison actually matched: a per-node junction surface
// for ordinary junctions, or the whole complex cluster for templated ones.
const matchingNative = { type: "FeatureCollection", features: result.matchedFeatures || [] };
fs.writeFileSync(svg, localReferenceSvg({ converted: result.converted, nativeIntersection: matchingNative, center: result.matchedOsmNode.coordinate }));
console.log(JSON.stringify({ output, comparison, svg, ...result, converted: undefined, matchedFeatures: undefined }, null, 2));
}
if (require.main === module) main();

View File

@@ -113,6 +113,7 @@ function normalizeAreaConfig(raw, options = {}) {
}, },
nativeRoad: { nativeRoad: {
edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"), edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"),
junctionTemplates: normalizeJunctionTemplates(raw.nativeRoad?.junctionTemplates, repoRoot),
}, },
osm2streets: raw.osm2streets || { osm2streets: raw.osm2streets || {
debug_each_step: false, debug_each_step: false,
@@ -132,6 +133,44 @@ function normalizeAreaConfig(raw, options = {}) {
}; };
} }
function normalizeJunctionTemplates(raw, repoRoot) {
if (raw === undefined || raw === null) return { enabled: false, references: [] };
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("nativeRoad.junctionTemplates must be an object");
const enabled = booleanOption(raw.enabled, false, "nativeRoad.junctionTemplates.enabled");
if (raw.references !== undefined && !Array.isArray(raw.references)) throw new Error("nativeRoad.junctionTemplates.references must be an array");
const references = (raw.references || []).map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`nativeRoad.junctionTemplates.references[${index}] must be an object`);
const nodeId = requireText(item.nodeId, `nativeRoad.junctionTemplates.references[${index}].nodeId`);
const template = item.template ?? "cross-v1";
if (template !== "cross-v1") throw new Error(`nativeRoad.junctionTemplates.references[${index}].template must be \"cross-v1\"`);
const referenceFile = item.referenceFile ? path.resolve(item.referenceFile) : null;
const coordinateSystem = item.coordinateSystem ?? "GCJ-02";
if (coordinateSystem !== "GCJ-02") throw new Error(`nativeRoad.junctionTemplates.references[${index}].coordinateSystem must be \"GCJ-02\"`);
const cornerRadiusMultiplier = numberOption(item.cornerRadiusMultiplier, 1, `nativeRoad.junctionTemplates.references[${index}].cornerRadiusMultiplier`, 0.75, 1.25);
const cutbackMultiplier = numberOption(item.cutbackMultiplier, 1, `nativeRoad.junctionTemplates.references[${index}].cutbackMultiplier`, 1, 1.35);
const approachWidthMultiplier = numberOption(item.approachWidthMultiplier, 1, `nativeRoad.junctionTemplates.references[${index}].approachWidthMultiplier`, 1, 1.8);
const approachLengthMeters = numberOption(item.approachLengthMeters, 24, `nativeRoad.junctionTemplates.references[${index}].approachLengthMeters`, 10, 50);
return { nodeId, template, referenceFile, coordinateSystem, cornerRadiusMultiplier, cutbackMultiplier, approachWidthMultiplier, approachLengthMeters };
});
if (raw.clusters !== undefined && !Array.isArray(raw.clusters)) throw new Error("nativeRoad.junctionTemplates.clusters must be an array");
const clusters = (raw.clusters || []).map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`nativeRoad.junctionTemplates.clusters[${index}] must be an object`);
const id = requireText(item.id, `nativeRoad.junctionTemplates.clusters[${index}].id`);
const nodeIds = item.nodeIds;
if (!Array.isArray(nodeIds) || nodeIds.length < 2 || nodeIds.some((value) => typeof value !== "string" && typeof value !== "number")) throw new Error(`nativeRoad.junctionTemplates.clusters[${index}].nodeIds must contain at least two node ids`);
const template = item.template ?? "complex-junction-v1";
if (template !== "cross-cluster-v1" && template !== "complex-junction-v1") throw new Error(`nativeRoad.junctionTemplates.clusters[${index}].template must be \"cross-cluster-v1\" or \"complex-junction-v1\"`);
const referenceFile = item.referenceFile ? path.resolve(item.referenceFile) : null;
const approachWidthMultiplier = numberOption(item.approachWidthMultiplier, 1.45, `nativeRoad.junctionTemplates.clusters[${index}].approachWidthMultiplier`, 1, 1.8);
const approachLengthMeters = numberOption(item.approachLengthMeters, 32, `nativeRoad.junctionTemplates.clusters[${index}].approachLengthMeters`, 10, 50);
const coreRadiusMeters = numberOption(item.coreRadiusMeters, 28, `nativeRoad.junctionTemplates.clusters[${index}].coreRadiusMeters`, 12, 80);
const cornerRadiusMeters = numberOption(item.cornerRadiusMeters, 12, `nativeRoad.junctionTemplates.clusters[${index}].cornerRadiusMeters`, 4, 25);
const outerRadiusExtraMeters = numberOption(item.outerRadiusExtraMeters, 18, `nativeRoad.junctionTemplates.clusters[${index}].outerRadiusExtraMeters`, 18, 35);
return { id, nodeIds: nodeIds.map(String), template, referenceFile, approachWidthMultiplier, approachLengthMeters, coreRadiusMeters, cornerRadiusMeters, outerRadiusExtraMeters };
});
return { enabled, references, clusters };
}
function normalizeBudgetConfig(raw) { function normalizeBudgetConfig(raw) {
if (raw !== undefined && raw !== null && (typeof raw !== "object" || Array.isArray(raw))) { if (raw !== undefined && raw !== null && (typeof raw !== "object" || Array.isArray(raw))) {
throw new Error("budget must be an object"); throw new Error("budget must be an object");

View File

@@ -0,0 +1,468 @@
"use strict";
const fs = require("fs");
const { convertGeoJson, boundsOf } = require("./gaode-junction-reference");
const metricsCache = new WeakMap();
const CORNER_FILLET_SEGMENTS = 12;
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
// lines up with the straight strips it joins.
const SIDEWALK_WIDTH_METERS = 2;
// The straight strips are trimmed against the cluster boundary using the road
// centerline, so they stop a little beyond the carriageway end. Run the corner
// past that end and let the two overlap rather than chase an exact seam.
const SIDEWALK_CORNER_OVERRUN_METERS = 6;
function buildComplexJunctionGeometry(model, cluster, helpers) {
const nodeIds = new Set(cluster.nodeIds.map(String));
const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean);
if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] };
const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
const approaches = [];
const carriageways = [];
for (const [nodeId, plan] of helpers.junctionPlans) {
if (!nodeIds.has(String(nodeId))) continue;
for (const approach of plan.approaches) {
const endpoint = approach.line.at(-1);
if (nodes.some((node) => node !== plan.node && helpers.distanceMeters(endpoint, node) < 4)) continue;
const heading = helpers.headingAtEndpoint(approach.line);
const length = helpers.lineLengthMeters(approach.line);
carriageways.push({ nodeId, approach, plan, heading, length });
if (approaches.some((item) => Math.abs(normalizeHeading(item.heading - heading)) < 20)) continue;
approaches.push({ nodeId, approach, plan, heading, length });
}
}
if (approaches.length < 3) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-approaches", "复合路口无法识别足够的外部进口。", center)] };
const { calibration, coreRadius } = complexJunctionMetrics(cluster);
const sorted = [...approaches].sort((a, b) => a.heading - b.heading);
const arms = sorted.map((representative) => ({
representative,
heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)),
members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20),
}));
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const boundaryParts = [];
const crosswalks = [];
const stopLines = [];
const islands = [];
const armCrosswalkRadius = coreRadius * .68;
for (const item of carriageways) {
const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers);
const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers);
const outerHalf = item.approach.widthMeters / 2;
const innerHalf = outerHalf;
boundaryParts.push({ item, outer, inner, outerHalf, innerHalf });
const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId));
if (incomingRoad) {
// Keep the stop bar just outside the road crosswalk. The previous fixed
// core-radius offset placed it nearly ten metres beyond the crossing.
const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers);
const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24),
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
];
stopLines.push({ type: "Feature", properties: { native_id: `complex-stop-line:${cluster.id}:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-stop-line", road_id: incomingRoad.id, node_id: item.nodeId, direction: item.heading, provenance: "native-road-complex-junction-stop-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
// The four support lines provide a common corner frame, but each long
// crossing remains clipped to its OSM-derived road envelope. Corner islands
// fill the remaining frame gaps; crossings must never do that job.
for (const arm of arms) {
const envelope = armEnvelopeAtRadius(arm, armCrosswalkRadius, center, helpers);
if (!envelope) continue;
arm.crosswalkFrame = {
center: envelope.center,
groupDepth: 3.4,
supportHeading: normalizeHeading(arm.heading + 90),
envelopeWidthMeters: envelope.widthMeters,
};
}
const frameCorners = arms.map((arm, index) => {
const next = arms[(index + 1) % arms.length];
const delta = positiveHeadingDelta(arm.heading, next.heading);
if (delta < 45 || delta > 135 || !arm.crosswalkFrame || !next.crosswalkFrame) return null;
return supportLineIntersection(arm.crosswalkFrame, next.crosswalkFrame, center);
});
for (let index = 0; index < arms.length; index += 1) {
const arm = arms[index];
if (!arm.crosswalkFrame) continue;
const item = arm.representative;
const roadEdgeInset = .35;
const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
const endpoints = [
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2),
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2),
];
const groupDepth = arm.crosswalkFrame.groupDepth;
const stripeWidth = .42;
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1);
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
arm.crosswalkFrame.center = midpoint(...endpoints);
arm.crosswalkFrame.endpoints = endpoints;
arm.crosswalkFrame.spanMeters = usableSpan;
arm.crosswalkFrame.roadEdgeInsetMeters = roadEdgeInset;
for (let stripe = 0; stripe < stripeCount; stripe += 1) {
const along = stripeWidth / 2 + stripe * stripeSpacing;
const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along);
const ring = [
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2),
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
];
crosswalks.push({ type: "Feature", properties: { native_id: `complex-crosswalk:${cluster.id}:${item.nodeId}:${item.approach.segmentId}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-crosswalk", crossing_node_id: item.nodeId, road_id: item.approach.roadIds[0], direction: arm.heading, radial_distance_m: armCrosswalkRadius, span_m: usableSpan, road_envelope_span_m: arm.crosswalkFrame.envelopeWidthMeters, road_edge_inset_m: roadEdgeInset, group_depth_m: groupDepth, stripe_width_m: stripeWidth, stripe_spacing_m: stripeSpacing, frame_center: arm.crosswalkFrame.center, frame_support_heading: arm.crosswalkFrame.supportHeading, provenance: "native-road-complex-junction-crosswalk/v6-road-clipped" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
// The four arm groups are the sides of one pedestrian frame. Each diagonal
// group is anchored at the intersection of its adjacent side support lines,
// so all eight groups stay one composition when the OSM arms are skewed.
for (let index = 0; index < arms.length; index += 1) {
const first = arms[index];
const second = arms[(index + 1) % arms.length];
const delta = positiveHeadingDelta(first.heading, second.heading);
if (delta < 45 || delta > 135) continue;
if (!first.crosswalkFrame || !second.crosswalkFrame) continue;
const bisector = normalizeHeading(first.heading + delta / 2);
const frameCorner = frameCorners[index];
if (!frameCorner) continue;
const cornerStripeSpacing = .62;
const cornerStripeWidth = .4;
const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2;
const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0];
const outerEdgeAtCorner = (arm) => {
const endpoint = endpointForCorner(arm);
return [arm.heading, arm.heading + 180]
.map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2))
.sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0];
};
const islandBaseGap = .05;
const islandApexOffset = 1.5;
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap));
const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset);
const islandCrossingClearance = .2;
const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth;
const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector)));
const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector);
const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset);
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24);
islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } });
let cornerCrossingHalfSpan = .4;
for (let stripe = 0; stripe < 6; stripe += 1) {
const stripeOffset = (stripe - 2.5) * cornerStripeSpacing;
const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset);
const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector);
const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers);
if (!curbPair) continue;
const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
const stripePair = [
helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan),
helpers.offsetCoordinate(stripeCenter, bisector + 90, halfSpan),
];
const ring = [
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[1], bisector, -cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[1], bisector, cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2),
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
];
crosswalks.push({ type: "Feature", properties: { native_id: `complex-corner-crosswalk:${cluster.id}:${index + 1}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-corner-crosswalk", corner_index: index + 1, direction: bisector, radial_distance_m: stripeRadius, frame_corner: frameCorner, from_heading: first.heading, to_heading: second.heading, provenance: "native-road-complex-junction-corner-crosswalk/v3" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
// Each carriageway ends in its own rectangle, so adjacent arms meet at a
// sharp notch instead of a curb. A real corner is one tangent-continuous
// sweep between the two outermost road edges, so fit a fixed-radius fillet
// into the wedge those edges form and fill the sector behind it.
const cornerFills = [];
const sidewalkCorners = [];
const cornerDiagnostics = [];
const cornerRadius = Math.max(4, Math.min(25, Number(cluster.cornerRadiusMeters) || 12));
for (let index = 0; index < arms.length; index += 1) {
const first = arms[index];
const second = arms[(index + 1) % arms.length];
const delta = positiveHeadingDelta(first.heading, second.heading);
if (delta < 45 || delta > 135) continue;
const bisector = normalizeHeading(first.heading + delta / 2);
const edges = [first, second].map((arm) => cornerEdgeAt(arm, coreRadius + 6, bisector, center, helpers));
if (!edges.every(Boolean)) continue;
const apex = rayIntersection(edges[0], edges[1], center);
const apexReach = apex ? directionalProjectionMeters(center, apex, bisector) : null;
// The wedge apex has to sit ahead of the core and inside the arm handoff;
// outside that band the two edges are near parallel and any fillet fitted
// to them would sweep across the carriageways instead of the corner.
if (apexReach === null || apexReach < 1 || apexReach > outerRadius) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center));
continue;
}
// Tangent distance for a circle of `cornerRadius` inscribed in a wedge of
// opening `delta`, clamped so the tangent points stay on the built arms.
const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach));
const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance));
const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS);
const ring = [...curve, center, curve[0]];
if (!ring.every((point) => point.every(Number.isFinite))) continue;
cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
// The straight pedestrian strips are trimmed at the cluster boundary, so
// two arms that both carry a footway still meet as two loose ends across
// an empty wedge. Bridge them along the curb the fillet already defines.
// The corner faces clockwise from `first` and counter-clockwise from
// `second`, so each arm must carry the footway on that facing side.
if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue;
const curb = [
...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(),
...curve.slice(1, -1),
...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers),
];
const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers);
const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]];
if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) {
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center));
continue;
}
sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } });
}
const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second));
const coreRing = roundedPolygonRing(corePoints, .16);
const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }];
for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) {
const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)];
features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
// Corner fills come last so they overlay the rectangular carriageway ends
// they are smoothing; they never replace an OSM-derived road surface.
features.push(...cornerFills);
// `coreRadiusMeters` is only consulted when there is no reference geometry.
// Under calibration the radius comes from the reference span, so a configured
// value that silently does nothing has to be reported, not swallowed.
const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5;
const configurationDiagnostics = configuredRadiusIgnored
? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)]
: [];
return { features, islands: [...islands, ...sidewalkCorners], crosswalks, stopLines, center, approaches, diagnostics: [...cornerDiagnostics, ...configurationDiagnostics, helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], calibration ? "complex-junction-reference-calibrated" : "complex-junction-generated", calibration ? `已使用参考几何校准参数后,由 OSM/native 重新生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。` : `已独立生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。`, center)] };
}
function readReferenceCalibration(cluster) {
if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null;
try {
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8")));
const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) });
const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180);
return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 };
} catch (_) {
return null;
}
}
function complexJunctionMetrics(cluster) {
if (metricsCache.has(cluster)) return metricsCache.get(cluster);
const calibration = readReferenceCalibration(cluster);
const coreRadius = calibration
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14))
: Math.max(11, Math.min(17, cluster.coreRadiusMeters * .52));
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) };
metricsCache.set(cluster, metrics);
return metrics;
}
function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
// `arm.heading` points outward from the junction, so the corner clockwise from
// it sits at heading+90 and the one counter-clockwise at heading-90. A road's
// own sidewalk flags are relative to its digitisation direction, so flip them
// whenever the arm runs against that direction.
function armCarriesSidewalk(arm, model, cornerIsClockwise) {
return arm.members.some((member) => member.approach.roadIds
.map((roadId) => model.roads.find((road) => road.id === roadId))
.filter(Boolean)
.some((road) => {
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight;
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide);
}));
}
// Walk outward along a wedge edge from its tangent point until the curb reaches
// `targetRadius`, so the corner band overlaps the straight strip it joins.
function edgeRunToRadius(apex, edge, tangentDistance, targetRadius, center, helpers) {
const points = [];
for (let extra = 0; extra <= 40; extra += 2) {
const point = helpers.offsetCoordinate(apex, edge.heading, tangentDistance + extra);
points.push(point);
if (helpers.distanceMeters(point, center) >= targetRadius) break;
}
return points;
}
// Offset each vertex along the polyline normal that increases distance from the
// junction centre. The curb is star-shaped around that centre, so "farther from
// the centre" is a reliable stand-in for "on the pedestrian side".
function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
return points.map((point, index) => {
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const tangent = previous === next ? 0 : bearing(previous, next);
return [tangent + 90, tangent - 90]
.map((heading) => helpers.offsetCoordinate(point, heading, meters))
.sort((first, second) => helpers.distanceMeters(second, center) - helpers.distanceMeters(first, center))[0];
});
}
function ringSelfIntersects(ring) {
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const straddles = (p1, p2, p3, p4) => {
const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2);
const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
};
for (let first = 0; first < ring.length - 1; first += 1) {
for (let second = first + 2; second < ring.length - 1; second += 1) {
if (first === 0 && second === ring.length - 2) continue;
if (straddles(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
}
}
return false;
}
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
function signedLateralMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
const north = (point[1] - origin[1]) * 111320;
const radians = (heading + 90) * Math.PI / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function bearing(first, second) {
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180);
const north = second[1] - first[1];
return Math.atan2(east, north) * 180 / Math.PI;
}
function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; }
function averageHeading(headings) {
const vector = headings.reduce((sum, heading) => {
const radians = heading * Math.PI / 180;
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
}, [0, 0]);
return Math.atan2(vector[0], vector[1]) * 180 / Math.PI;
}
function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; }
function pointOnCarriagewayRadius(item, center, radius, helpers) {
const start = item.approach.line[0];
const startRadius = directionalProjectionMeters(center, start, item.heading);
return helpers.pointAlongLine(item.approach.line, Math.max(0, Math.min(item.length, radius - startRadius)));
}
function armEnvelopeAtRadius(arm, radius, center, helpers) {
if (!arm.members.length) return null;
const centers = arm.members.map((member) => pointOnCarriagewayRadius(member, center, radius, helpers));
const reference = centers[0];
let minimum = Infinity;
let maximum = -Infinity;
centers.forEach((point, index) => {
const lateral = signedLateralMeters(reference, point, arm.heading);
const halfWidth = arm.members[index].approach.widthMeters / 2;
minimum = Math.min(minimum, lateral - halfWidth);
maximum = Math.max(maximum, lateral + halfWidth);
});
if (!Number.isFinite(minimum) || maximum - minimum < 1) return null;
return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum };
}
function supportLineIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const firstPoint = toLocal(first.center);
const secondPoint = toLocal(second.center);
const direction = (heading) => {
const radians = heading * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstDirection = direction(first.supportHeading);
const secondDirection = direction(second.supportHeading);
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(denominator) < 1e-6) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320];
}
function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) {
const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)];
if (!pair.every(Boolean)) return null;
const width = helpers.distanceMeters(pair[0], pair[1]);
const middle = midpoint(pair[0], pair[1]);
const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2);
const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90;
return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)];
}
function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) {
return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null;
}
function cornerEdgeAt(arm, radius, bisector, center, helpers) {
const candidates = arm.members.flatMap((member) => {
const point = pointOnCarriagewayRadius(member, center, radius, helpers);
const halfWidth = member.approach.widthMeters / 2;
return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading }));
});
return candidates.sort((first, second) => directionalProjectionMeters(center, second.point, bisector) - directionalProjectionMeters(center, first.point, bisector))[0] || null;
}
function rayIntersection(first, second, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const direction = (heading) => {
const radians = heading * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
};
const firstPoint = toLocal(first.point);
const secondPoint = toLocal(second.point);
const firstDirection = direction(first.heading);
const secondDirection = direction(second.heading);
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(denominator) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
if (!local.every(Number.isFinite)) return null;
return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320];
}
function quadraticCurve(start, control, end, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments;
const u = 1 - t;
result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]);
}
return result;
}
function directionalProjectionMeters(origin, point, heading) {
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
const north = (point[1] - origin[1]) * 111320;
const radians = heading * Math.PI / 180;
return east * Math.sin(radians) + north * Math.cos(radians);
}
function smoothClosedRing(vertices) {
// Curb-edge candidates can arrive in opposite winding orders when an OSM
// carriageway bends slightly. Sort this local corner only around its own
// centroid before rounding, avoiding a self-crossing safety island while
// keeping the global junction boundary fully OSM-driven.
const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0]));
const points = ordered.flatMap((point, index) => {
const next = ordered[(index + 1) % ordered.length];
return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)];
});
return [...points, points[0]];
}
function roundedPolygonRing(vertices, ratio) {
const points = vertices.flatMap((point, index) => {
const previous = vertices[(index - 1 + vertices.length) % vertices.length];
const next = vertices[(index + 1) % vertices.length];
return [interpolateCoordinate(previous, point, 1 - ratio), interpolateCoordinate(point, next, ratio)];
});
return [...points, points[0]];
}
function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; }
module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };

View File

@@ -0,0 +1,231 @@
"use strict";
const fs = require("fs");
const PI = Math.PI;
const EARTH_A = 6378245.0;
const EARTH_EE = 0.00669342162296594323;
function transformLat(x, y) {
let value = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3;
value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3;
return value;
}
function transformLon(x, y) {
let value = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3;
value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3;
value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3;
return value;
}
// This is the standard local inverse approximation used for GCJ-02 reference
// data. It is intentionally kept separate from native road geometry, whose
// source coordinates remain WGS84.
function gcj02ToWgs84(coordinate) {
const [longitude, latitude] = coordinate;
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("Reference coordinate must be finite");
const dLat = transformLat(longitude - 105, latitude - 35);
const dLon = transformLon(longitude - 105, latitude - 35);
const radLat = latitude / 180 * PI;
const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2;
const sqrtMagic = Math.sqrt(magic);
return [
longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI),
latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI),
];
}
function mapCoordinates(coordinates, mapper) {
if (typeof coordinates[0] === "number") return mapper(coordinates);
return coordinates.map((value) => mapCoordinates(value, mapper));
}
function convertGeoJson(document) {
if (!document || document.type !== "FeatureCollection" || !Array.isArray(document.features)) {
throw new Error("Reference must be a GeoJSON FeatureCollection");
}
return {
...document,
crs: undefined,
features: document.features.map((feature) => {
if (!feature || !feature.geometry || !feature.geometry.coordinates) throw new Error("Reference feature is missing geometry");
return { ...feature, geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) } };
}),
};
}
function coordinatesOf(document) {
const points = [];
for (const feature of document.features || []) walkCoordinates(feature.geometry?.coordinates, points);
return points;
}
function walkCoordinates(value, points) {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") {
points.push(value);
return;
}
for (const child of value) walkCoordinates(child, points);
}
function boundsOf(document) {
const points = coordinatesOf(document);
if (!points.length) throw new Error("Reference contains no coordinates");
return {
minLon: Math.min(...points.map((point) => point[0])),
minLat: Math.min(...points.map((point) => point[1])),
maxLon: Math.max(...points.map((point) => point[0])),
maxLat: Math.max(...points.map((point) => point[1])),
};
}
function centerOf(bounds) {
return [(bounds.minLon + bounds.maxLon) / 2, (bounds.minLat + bounds.maxLat) / 2];
}
function distanceMeters(first, second) {
const lonScale = 111320 * Math.cos(first[1] * PI / 180);
return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320);
}
function parseOsmNodes(xml) {
const nodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = {};
for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[item[1]] = item[2] ?? item[3];
if (!attrs.id || !Number.isFinite(Number(attrs.lon)) || !Number.isFinite(Number(attrs.lat))) continue;
const tags = {};
for (const item of (match[2] || "").matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
const tag = {};
for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) tag[attr[1]] = attr[2] ?? attr[3];
if (tag.k) tags[tag.k] = tag.v || "";
}
nodes.push({ id: String(attrs.id), coordinate: [Number(attrs.lon), Number(attrs.lat)], tags });
}
return nodes;
}
function nearestNode(nodes, coordinate, nodeId) {
if (nodeId) {
const exact = nodes.find((node) => node.id === String(nodeId));
if (!exact) throw new Error(`OSM node not found: ${nodeId}`);
return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: "node-id" };
}
const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) }));
candidates.sort((first, second) => first.distanceMeters - second.distanceMeters);
if (!candidates[0]) throw new Error("OSM contains no usable nodes");
return { ...candidates[0], match: "nearest-node" };
}
function bboxIntersectionRatio(first, second) {
const width = Math.max(0, Math.min(first.maxLon, second.maxLon) - Math.max(first.minLon, second.minLon));
const height = Math.max(0, Math.min(first.maxLat, second.maxLat) - Math.max(first.minLat, second.minLat));
const intersection = width * height;
const firstArea = Math.max(0, first.maxLon - first.minLon) * Math.max(0, first.maxLat - first.minLat);
const secondArea = Math.max(0, second.maxLon - second.minLon) * Math.max(0, second.maxLat - second.minLat);
return intersection / Math.max(firstArea + secondArea - intersection, Number.EPSILON);
}
// A complex junction is compiled as one cluster of `complex_part` polygons in
// road_surface.geojson, not as a per-node feature in intersection_surface.
// Match it by cluster id, or by whichever cluster core sits nearest the node.
function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) {
if (!nativeRoadSurfaceFile || !fs.existsSync(nativeRoadSurfaceFile)) return null;
const surface = JSON.parse(fs.readFileSync(nativeRoadSurfaceFile, "utf8"));
const parts = (surface.features || []).filter((item) => item.properties?.cluster_id && item.properties?.complex_part);
const cores = parts.filter((item) => item.properties.complex_part === "core" && Array.isArray(item.properties.center));
if (!cores.length) return null;
const core = clusterId
? cores.find((item) => String(item.properties.cluster_id) === String(clusterId))
: [...cores].sort((first, second) => distanceMeters(first.properties.center, node.coordinate) - distanceMeters(second.properties.center, node.coordinate))[0];
if (!core) return null;
const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id);
return { clusterId: core.properties.cluster_id, core, features };
}
function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nativeRoadSurfaceFile, nodeId, clusterId }) {
const source = JSON.parse(fs.readFileSync(referenceFile, "utf8"));
const converted = convertGeoJson(source);
const referenceBounds = boundsOf(converted);
const referenceCenter = centerOf(referenceBounds);
const nodes = parseOsmNodes(fs.readFileSync(osmFile, "utf8"));
const matchedNode = nearestNode(nodes, referenceCenter, nodeId);
const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, "utf8"));
const feature = (native.features || []).find((item) => item.properties?.osm_node_id === matchedNode.id);
const cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId);
const matchedFeatures = feature ? [feature] : cluster?.features || null;
const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null;
const diagnostics = [];
if (!matchedFeatures) diagnostics.push(nativeRoadSurfaceFile ? "No native intersection surface or complex cluster matched the OSM node" : "No native intersection surface matched the OSM node; pass --native-road-surface to also search complex junction clusters");
return {
schema: "gaode-junction-reference-comparison/v2",
source: { file: referenceFile, coordinateSystem: "GCJ-02", featureCount: converted.features.length },
conversion: { target: "WGS84", method: "gcj02-inverse-approximation" },
reference: { bounds: referenceBounds, center: referenceCenter },
matchedOsmNode: { id: matchedNode.id, coordinate: matchedNode.coordinate, tags: matchedNode.tags, match: matchedNode.match, centerDistanceMeters: matchedNode.distanceMeters },
nativeIntersection: nativeBounds ? {
kind: feature ? "junction-node" : "complex-cluster",
clusterId: cluster?.clusterId || null,
featureCount: matchedFeatures.length,
bounds: nativeBounds,
bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds),
centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)),
featureProperties: feature ? feature.properties : cluster.core.properties,
} : null,
diagnostics,
converted,
matchedFeatures,
};
}
function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) {
const width = 1000;
const height = 1000;
const lonScale = 111320 * Math.cos(center[1] * PI / 180);
const project = (point) => [
width / 2 + (point[0] - center[0]) * lonScale * width / (radiusMeters * 2),
height / 2 - (point[1] - center[1]) * 111320 * height / (radiusMeters * 2),
];
const pathFor = (coordinates) => {
const parts = [];
const appendLine = (line, close) => {
if (!line?.length) return;
const [firstX, firstY] = project(line[0]);
parts.push(`M ${firstX.toFixed(1)} ${firstY.toFixed(1)}`);
for (const point of line.slice(1)) {
const [x, y] = project(point);
parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`);
}
if (close) parts.push("Z");
};
const visit = (value) => {
if (!Array.isArray(value) || !value.length) return;
if (typeof value[0] === "number") return;
if (typeof value[0][0] === "number") appendLine(value, value.length > 2);
else value.forEach(visit);
};
visit(coordinates);
return parts.join(" ");
};
const color = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
const references = converted.features.map((feature) => {
const type = feature.properties?.type || "unknown";
return `<path d="${pathFor(feature.geometry.coordinates)}" fill="${feature.geometry.type.includes("Polygon") ? `${color[type] || "#334155"}18` : "none"}" stroke="${color[type] || "#334155"}" stroke-width="1.2"/>`;
}).join("\n");
const nativePaths = (nativeIntersection?.features || []).map((feature) => `<path d="${pathFor(feature.geometry.coordinates)}" fill="#dc262655" stroke="#dc2626" stroke-width="3"/>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#f8fafc"/>
${references}
${nativePaths}
<circle cx="${width / 2}" cy="${height / 2}" r="5" fill="#111827"/>
<text x="20" y="35" font-family="sans-serif" font-size="22" fill="#111827">Gaode reference (type colors) / native intersection (red)</text>
</svg>`;
}
module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg };

View File

@@ -3,6 +3,7 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows"); const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require("./complex-junction");
const OVERRIDE_SCHEMA = "native-road-overrides/v1"; const OVERRIDE_SCHEMA = "native-road-overrides/v1";
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]); const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
@@ -22,6 +23,9 @@ const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
const CENTER_LINE_COLORS = new Set(["yellow", "white"]); const CENTER_LINE_COLORS = new Set(["yellow", "white"]);
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]); const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .05; const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .05;
// A lane centerline is drawn as a hairline, so probe the crossing with a narrow
// band. Using the full lane width would clip the line metres early.
const LANE_CENTERLINE_PROBE_WIDTH_METERS = .12;
const JUNCTION_CURVE_SEGMENTS = 8; const JUNCTION_CURVE_SEGMENTS = 8;
function parseOsmRoads(xml) { function parseOsmRoads(xml) {
@@ -244,33 +248,171 @@ function nearbyManualCandidates(endpoints, from) {
function compileGeometry(model, overrides = { overrides: [] }, options = {}) { function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const diagnostics = [...model.diagnostics]; const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model); const junctionPlans = compileJunctionPlans(model, options, diagnostics);
const features = []; const features = [];
const activeClusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
const complexClusterCenters = new Map(activeClusters.filter((cluster) => cluster.template === "complex-junction-v1").map((cluster) => {
const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean);
const center = points.length ? points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]) : null;
return [cluster.id, center];
}));
const emittedSegments = new Set(); const emittedSegments = new Set();
const generatedComplexSidewalks = [];
const generatedComplexCrosswalks = [];
const generatedComplexStopLines = [];
for (const road of model.roads) { for (const road of model.roads) {
const segmentKey = road.segmentId; const segmentKey = road.segmentId;
if (emittedSegments.has(segmentKey)) continue; if (emittedSegments.has(segmentKey)) continue;
emittedSegments.add(segmentKey); emittedSegments.add(segmentKey);
const directions = model.roads.filter((item) => item.segmentId === segmentKey); const directions = model.roads.filter((item) => item.segmentId === segmentKey);
const startCluster = clusterByNode.get(String(road.sourceNodeIds[0]));
const endCluster = clusterByNode.get(String(road.sourceNodeIds.at(-1)));
if (startCluster?.template === "complex-junction-v1" && endCluster?.template === "complex-junction-v1" && startCluster.id === endCluster.id) continue;
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
// The approach surface stops at the junction cutback. The junction layer // The approach surface stops at the junction cutback. The junction layer
// owns the intervening rounded corners; leaving approaches untrimmed // owns the intervening rounded corners; leaving approaches untrimmed
// would cover that outline with rectangular road ends in Blender/Cesium. // would cover that outline with rectangular road ends in Blender/Cesium.
const ring = roadRing(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), totalWidth); const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
if (cluster?.template === "complex-junction-v1") {
const center = complexClusterCenters.get(cluster.id);
const length = lineLengthMeters(road.centerline);
const farEndpoint = cluster.nodeIds.map(String).includes(String(road.sourceNodeIds[0])) ? road.centerline.at(-1) : road.centerline[0];
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
if (center && length < outerRadius + 8 && distanceMeters(farEndpoint, center) < outerRadius) continue;
}
const line = cluster?.template === "complex-junction-v1"
? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, cluster, complexClusterCenters.get(cluster.id))
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
const ring = roadRing(line, totalWidth);
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; } if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`; const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); features.push({ type: "Feature", properties: { native_id: surfaceId, cluster_id: cluster?.template === "complex-junction-v1" ? cluster.id : null, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
} }
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); for (const [nodeId, plan] of junctionPlans) {
if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue;
if (!plan.template) continue;
for (const approach of plan.approaches) {
const transition = templateApproachRing(approach, plan);
if (!transition) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-approach-fallback", "进口道路长度不足以生成规整过渡面,已保留该进口的 native 直筒道路。", plan.node));
continue;
}
features.push({ type: "Feature", properties: { native_id: `junction-approach:${plan.template}:node/${nodeId}:${approach.segmentId}`, osm_node_id: nodeId, segment_id: approach.segmentId, directional_road_ids: approach.roadIds.join(","), width_m: approach.widthMeters, approach_width_m: Math.round(approach.widthMeters * plan.approachWidthMultiplier * 10) / 10, approach_length_m: Math.round(transition.lengthMeters * 10) / 10, template: plan.template, provenance: "native-road-junction-approach-template/v1" }, geometry: { type: "Polygon", coordinates: [transition.ring] } });
}
}
for (const cluster of activeClusters) {
if (cluster.template !== "complex-junction-v1") continue;
const generated = buildComplexJunctionGeometry(model, cluster, { junctionPlans, diagnostic, distanceMeters, lineLengthMeters, pointAlongLine, offsetCoordinate, headingAtEndpoint, headingVector, circleRing });
features.push(...generated.features);
if (generated.crosswalks) generatedComplexCrosswalks.push(...generated.crosswalks);
if (generated.stopLines) generatedComplexStopLines.push(...generated.stopLines);
if (generated.islands) generatedComplexSidewalks.push(...generated.islands);
diagnostics.push(...generated.diagnostics);
}
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, { crosswalks: generatedComplexCrosswalks, stopLines: generatedComplexStopLines });
const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans); const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans);
const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans); const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines] };
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls); const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, allControls, options);
markings.separators.push(...compileComplexLaneSeparators(lanes.features, [...allControls.crosswalks, ...allControls.stopLines]));
markings.directionArrows.push(...compileComplexPreviewArrows(lanes.features, generatedComplexStopLines));
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans, options);
// Complex crosswalks are generated from native approach tangents; do not
// synthesize side strips that can be mistaken for crosswalks.
sidewalks.push(...generatedComplexSidewalks);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics); const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics, options);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics); validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics }; return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: [...controls.crosswalks, ...generatedComplexCrosswalks] }, vehicleStopLines: { type: "FeatureCollection", features: [...controls.stopLines, ...generatedComplexStopLines] }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
}
function compileComplexPreviewArrows(features, stopLines) {
const result = [];
for (const feature of features) {
if (!feature.properties?.cluster_preview || !feature.properties.incoming || feature.properties.maneuver === "outbound") continue;
const line = feature.geometry?.coordinates || [];
if (line.length < 2) continue;
const stopLine = stopLines.find((candidate) => candidate.properties?.road_id === feature.properties.road_id);
if (!stopLine) continue;
const stopRing = stopLine.geometry?.coordinates?.[0];
if (!stopRing || stopRing.length < 4) continue;
const stopPoints = stopRing.slice(0, -1);
const stopCenter = [stopPoints.reduce((sum, point) => sum + point[0], 0) / stopPoints.length, stopPoints.reduce((sum, point) => sum + point[1], 0) / stopPoints.length];
const placement = distanceMeters(line.at(-1), stopCenter) + 8;
const placementInfo = pointAndAxisAlongLine(line, Math.max(0, lineLengthMeters(line) - placement));
if (!placementInfo) continue;
const rings = arrowRingsAt(feature.properties.maneuver, placementInfo.point, placementInfo.axis);
for (let part = 0; part < rings.length; part += 1) result.push({
type: "Feature",
properties: { native_id: `${feature.properties.native_id}:arrow:${part}`, road_id: feature.properties.road_id, lane_id: feature.properties.native_id, cluster_id: feature.properties.cluster_id, cluster_preview: true, maneuver: feature.properties.maneuver, travel_heading_deg: headingDegrees(line[0], line.at(-1)), placement_distance_from_stop_meters: 8, provenance: "native-road-complex-preview-arrow/v2-stop-anchored" },
geometry: { type: "Polygon", coordinates: [rings[part]] },
});
}
return result;
}
function compileComplexLaneSeparators(features, controls = []) {
const groups = new Map();
for (const feature of features) {
if (!feature.properties?.cluster_preview || !feature.properties.road_id) continue;
if (!groups.has(feature.properties.road_id)) groups.set(feature.properties.road_id, []);
groups.get(feature.properties.road_id).push(feature);
}
const result = [];
for (const [roadId, lanes] of groups) {
lanes.sort((first, second) => first.properties.lane_index - second.properties.lane_index);
for (let index = 1; index < lanes.length; index += 1) {
const left = lanes[index - 1].geometry.coordinates; const right = lanes[index].geometry.coordinates;
if (left.length !== right.length) continue;
const line = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const visibleLine = trimLineBeforeFirstControl(line, controls, .12);
const ring = visibleLine ? roadRing(visibleLine, .12) : null;
if (!ring) continue;
result.push({ type: "Feature", properties: { native_id: `complex-lane-separator:${roadId}:${index}-${index + 1}`, road_id: roadId, cluster_id: lanes[0].properties.cluster_id, left_lane_index: index, right_lane_index: index + 1, color: "white", pattern: "solid", effective_style: "white-solid", provenance: "native-road-complex-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
return result;
}
function trimLineBeforeFirstControl(line, controls, width) {
const total = lineLengthMeters(line);
const step = .25;
for (let distance = step; distance <= total; distance += step) {
const placement = pointAndAxisAlongLine(line, Math.min(total, distance - step / 2));
if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], step, width, 0);
if (!ringsOverlapControl([ring], controls)) continue;
let cutoff = Math.max(0, distance - step - .12);
while (cutoff > .5) {
const candidate = roadRing([line[0], pointAlongLine(line, cutoff)], width);
if (candidate && !ringsOverlapControl([candidate], controls)) return [line[0], pointAlongLine(line, cutoff)];
cutoff -= .25;
}
return null;
}
return line;
}
// `trimLineBeforeFirstControl` always keeps the head of the line, so the caller
// must hand it a line that already runs from the road towards the junction.
// Lane centerlines arrive in either orientation (a preview lane runs inward
// from the outer radius, an outgoing road lane runs outward from the cluster
// node), so orient by radius first and restore the original order afterwards.
function trimLaneOutsideControls(line, controls, width, center) {
if (!controls.length || !center || !Array.isArray(line) || line.length < 2) return line;
const outwardFirst = distanceMeters(line[0], center) >= distanceMeters(line.at(-1), center);
const oriented = outwardFirst ? line : [...line].reverse();
const trimmed = trimLineBeforeFirstControl(oriented, controls, width);
if (!trimmed) return null;
return outwardFirst ? trimmed : [...trimmed].reverse();
}
// Reference identity is not reliable here: the reversed path rebuilds the array
// even when nothing was cut. Compare travelled length instead.
function laneWasClipped(original, visible) {
return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - .01;
} }
function compileEdgeLines(model, overrides, junctionPlans) { function compileEdgeLines(model, overrides, junctionPlans) {
@@ -305,7 +447,7 @@ function edgeLineFeature(road, side, style, ring, part = null) {
return { type: "Feature", properties: { native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ""}`, road_id: road.id, side, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-edge-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; return { type: "Feature", properties: { native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ""}`, road_id: road.id, side, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-edge-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } };
} }
function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) { function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics, options = {}) {
const features = []; const features = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
const segments = new Map(); const segments = new Map();
@@ -317,18 +459,24 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti
const forward = roads.find((road) => road.direction === "forward"); const forward = roads.find((road) => road.direction === "forward");
const backward = roads.find((road) => road.direction === "backward"); const backward = roads.find((road) => road.direction === "backward");
if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue; if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue;
const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); const cluster = clusterForRoad(forward, options) || clusterForRoad(backward, options);
const internalCluster = cluster && roadInternalToCluster(forward, cluster);
const line = cluster
? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, clusterCenter(cluster, junctionPlans))
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const length = lineLengthMeters(line); const length = lineLengthMeters(line);
if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; } if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; }
const style = centerLineStyle(overrides, segmentId); const style = centerLineStyle(overrides, segmentId);
const visibleLine = trimLineBeforeFirstControl(line, controlFeatures, CENTER_LINE_WIDTH_METERS);
const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0;
if (!visibleLine || visibleLength < CENTER_LINE_DASH_LENGTH_METERS) continue;
const gap = style.pattern === "solid" ? 0 : CENTER_LINE_DASH_GAP_METERS; const gap = style.pattern === "solid" ? 0 : CENTER_LINE_DASH_GAP_METERS;
const markLength = CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === "solid" ? CENTER_LINE_SOLID_OVERLAP_METERS : 0); const markLength = CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === "solid" ? CENTER_LINE_SOLID_OVERLAP_METERS : 0);
for (let start = 0, dashIndex = 1; start + markLength <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) { for (let start = 0, dashIndex = 1; start + markLength <= visibleLength; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) {
const placement = pointAndAxisAlongLine(line, start + markLength / 2); const placement = pointAndAxisAlongLine(visibleLine, start + markLength / 2);
if (!placement) continue; if (!placement) continue;
const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0); const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0);
if (ringsOverlapControl([clearanceRing], controlFeatures)) continue; for (const offset of style.double ? [-.16, .16] : [0]) { const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, offset); features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, double: Boolean(style.double), effective_style: `${style.double ? "double-" : ""}${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); }
for (const offset of style.double ? [-.16, .16] : [0]) { const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, offset); features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, double: Boolean(style.double), effective_style: `${style.double ? "double-" : ""}${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); }
} }
} }
return features; return features;
@@ -381,21 +529,35 @@ function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meter
function rectangleAt(center, axis, across, length, width, offset) { const shifted = offsetByMeters(center, across, offset); const corners = [[-length / 2, -width / 2], [length / 2, -width / 2], [length / 2, width / 2], [-length / 2, width / 2]].map(([forward, side]) => unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted)); return [...corners, corners[0]]; } function rectangleAt(center, axis, across, length, width, offset) { const shifted = offsetByMeters(center, across, offset); const corners = [[-length / 2, -width / 2], [length / 2, -width / 2], [length / 2, width / 2], [-length / 2, width / 2]].map(([forward, side]) => unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted)); return [...corners, corners[0]]; }
function controlFeature(kind, crossing, candidate, part, ring, placement = {}) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100, provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; } function controlFeature(kind, crossing, candidate, part, ring, placement = {}) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100, provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; }
function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls) { function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls, options = {}) {
const separators = []; const directionArrows = []; const turnArrows = []; const separators = []; const directionArrows = []; const turnArrows = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
for (const road of model.roads) { for (const road of model.roads) {
const roadLanes = lanes.byRoadId.get(road.id) || []; const cluster = clusterForRoad(road, options);
const internalCluster = cluster && roadInternalToCluster(road, cluster);
const roadLanes = (lanes.markingByRoadId || lanes.byRoadId).get(road.id) || [];
for (let index = 1; index < roadLanes.length; index += 1) { for (let index = 1; index < roadLanes.length; index += 1) {
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates; const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
if (left.length !== right.length) continue; if (left.length !== right.length) continue;
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]); const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const style = laneSeparatorStyle(overrides, road.id, index, index + 1); const style = laneSeparatorStyle(overrides, road.id, index, index + 1);
const properties = { road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" }; const properties = { road_id: road.id, left_lane_index: index, right_lane_index: index + 1, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" };
if (style.pattern === "solid") { const ring = roadRing(centerline, 0.12); if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); } if (style.pattern === "solid") {
else for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) { const placement = pointAndAxisAlongLine(centerline, distance); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0); separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); } const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, .12);
const ring = visibleLine ? roadRing(visibleLine, .12) : null;
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } });
} else {
const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, .12);
const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0;
for (let distance = 1, part = 1; visibleLine && distance + 1 <= visibleLength; distance += 4, part += 1) {
const placement = pointAndAxisAlongLine(visibleLine, distance);
if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0);
separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
} }
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics)); for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics).map((feature) => ({ ...feature, properties: { ...feature.properties, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster) } })));
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"]; const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
const maneuvers = turns ? String(turns).split("|") : []; const maneuvers = turns ? String(turns).split("|") : [];
for (let index = 0; index < roadLanes.length; index += 1) { for (let index = 0; index < roadLanes.length; index += 1) {
@@ -413,12 +575,31 @@ function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans
const center = pointAlongLine([...lane.coordinates].reverse(), placement); const center = pointAlongLine([...lane.coordinates].reverse(), placement);
const rings = arrowRingsAt(maneuver, center, axis); const rings = arrowRingsAt(maneuver, center, axis);
if (!rings.length) continue; if (!rings.length) continue;
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: placement, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } }); for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: placement, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
} }
} }
return { separators, directionArrows, turnArrows }; return { separators, directionArrows, turnArrows };
} }
function clusterForRoad(road, options) {
const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
return clusters.find((cluster) => cluster.template === "complex-junction-v1" && road.sourceNodeIds.some((nodeId) => cluster.nodeIds.map(String).includes(String(nodeId)))) || null;
}
function roadInternalToCluster(road, cluster) {
const nodeIds = new Set(cluster.nodeIds.map(String));
return nodeIds.has(String(road.sourceNodeIds[0])) && nodeIds.has(String(road.sourceNodeIds.at(-1)));
}
function clusterCenter(cluster, junctionPlans) {
const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean);
return points.length ? points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]) : null;
}
function angularDistance(first, second) {
return Math.abs(((first - second + 180) % 360) - 180);
}
function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) { const value = overrides.overrides.find((item) => item.kind === "lane-separator-style" && item.roadId === roadId && item.leftLaneIndex === leftLaneIndex && item.rightLaneIndex === rightLaneIndex); return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "dashed" }; } function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) { const value = overrides.overrides.find((item) => item.kind === "lane-separator-style" && item.roadId === roadId && item.leftLaneIndex === leftLaneIndex && item.rightLaneIndex === rightLaneIndex); return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "dashed" }; }
function directionArrowFeatures(road, lane, controlFeatures, diagnostics) { function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
@@ -445,7 +626,7 @@ function ringsOverlap(first, second) {
return first.slice(1).some((point, index) => second.slice(1).some((other, otherIndex) => segmentsIntersect(first[index], point, second[otherIndex], other))); return first.slice(1).some((point, index) => second.slice(1).some((other, otherIndex) => segmentsIntersect(first[index], point, second[otherIndex], other)));
} }
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) { function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}) {
const features = []; const features = [];
const byWay = new Map(); const byWay = new Map();
for (const road of model.roads) { for (const road of model.roads) {
@@ -461,22 +642,27 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)], ["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)], ["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
]; ];
const cluster = clusterForRoad(forward, options) || (backward ? clusterForRoad(backward, options) : null);
const center = cluster ? clusterCenter(cluster, junctionPlans) : null;
for (const [side, enabled] of sides) { for (const [side, enabled] of sides) {
if (!enabled) continue; if (!enabled) continue;
const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); const centerline = cluster
? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, center)
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1); const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; } if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`; const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); features.push({ type: "Feature", properties: { native_id: sidewalkId, cluster_id: cluster?.id || null, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
} }
} }
features.push(...compileSidewalkCorners(model, junctionPlans)); features.push(...compileSidewalkCorners(model, junctionPlans, options));
return features; return features;
} }
function compileSidewalkCorners(model, junctionPlans) { function compileSidewalkCorners(model, junctionPlans, options = {}) {
const result = []; const result = [];
for (const [nodeId, plan] of junctionPlans) { for (const [nodeId, plan] of junctionPlans) {
if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue;
const candidates = []; const candidates = [];
for (const approach of plan.approaches) { for (const approach of plan.approaches) {
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId); const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
@@ -589,10 +775,15 @@ function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
} }
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) { function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature])); const junctionByNode = new Map();
for (const feature of junctionFeatures) {
if (feature.properties.osm_node_ids) for (const nodeId of String(feature.properties.osm_node_ids).split(",")) junctionByNode.set(nodeId, feature);
else if (feature.properties.osm_node_id) junctionByNode.set(feature.properties.osm_node_id, feature);
}
for (const connector of connectors) { for (const connector of connectors) {
const junction = junctionByNode.get(connector.properties.node_id); const junction = junctionByNode.get(connector.properties.node_id);
if (!junction) continue; if (!junction) continue;
if (junction.properties.kind === "cluster") continue;
const ring = junction.geometry.coordinates[0]; const ring = junction.geometry.coordinates[0];
if (!connector.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS))) { if (!connector.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS))) {
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0])); diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
@@ -627,14 +818,29 @@ function pointOnSegment(point, a, b) {
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12; return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
} }
function compileLaneCenterlines(model, diagnostics, junctionPlans) { // `complexControls` carries the crosswalks and stop bars the complex-junction
// templates already emitted. Ordinary controls cannot be passed here: they are
// placed *from* these lane centerlines, so only the template-generated ones
// exist this early.
function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {}, complexControls = {}) {
const features = []; const features = [];
const controlFeatures = [...(complexControls.crosswalks || []), ...(complexControls.stopLines || [])];
const byRoadId = new Map(); const byRoadId = new Map();
const markingByRoadId = new Map();
const clusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)]));
for (const road of model.roads) { for (const road of model.roads) {
const lanes = []; const lanes = [];
const markingLanes = [];
const laneWidth = road.widthMeters / road.laneCount; const laneWidth = road.widthMeters / road.laneCount;
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId); const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
const opposite = siblings.find((item) => item.id !== road.id); const opposite = siblings.find((item) => item.id !== road.id);
const boundaryCluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
const internalCluster = boundaryCluster && roadInternalToCluster(road, boundaryCluster);
const clippedRoadLine = boundaryCluster?.template === "complex-junction-v1"
? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, boundaryCluster, clusterCenters.get(boundaryCluster.id))
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
// OSM centerline is the shared carriageway center. On a two-way road, // OSM centerline is the shared carriageway center. On a two-way road,
// offset each directed carriageway to its own side before placing lanes. // offset each directed carriageway to its own side before placing lanes.
const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0; const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0;
@@ -643,14 +849,84 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans) {
// driver's left so tag positions and generated lane IDs have one meaning. // driver's left so tag positions and generated lane IDs have one meaning.
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5)); const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset); const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; } const publishedCoordinates = offsetLine(clippedRoadLine, offset);
if (!coordinates || !publishedCoordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates }; const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
lanes.push(lane); lanes.push(lane);
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, source: "native-road-lane-centerline/v1" }, geometry: { type: "LineString", coordinates } }); // Only the published geometry stops at the crossing. `coordinates` stays
// whole because connectors are derived from it; a lane that ends at the
// stop bar would otherwise break every turn path through the junction.
const visibleCoordinates = boundaryCluster?.template === "complex-junction-v1"
? trimLaneOutsideControls(publishedCoordinates, controlFeatures, LANE_CENTERLINE_PROBE_WIDTH_METERS, clusterCenters.get(boundaryCluster.id))
: publishedCoordinates;
if (!visibleCoordinates) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "lane-centerline-fully-inside-control", "该车道中心线整体落在斑马线或停止线内,已按未裁剪几何发布。", publishedCoordinates[0])); }
// Lane markings are laid out along this line. Feeding it the clipped
// geometry is what keeps separators and arrows from being painted *past*
// a crossing: control avoidance only stops them landing *on* one.
markingLanes.push({ ...lane, coordinates: visibleCoordinates || publishedCoordinates });
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, cluster_id: boundaryCluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), cluster_boundary_clipped: Boolean(boundaryCluster && !internalCluster), control_clipped: laneWasClipped(publishedCoordinates, visibleCoordinates), source: "native-road-lane-centerline/v3-control-clipped" }, geometry: { type: "LineString", coordinates: visibleCoordinates || publishedCoordinates } });
} }
byRoadId.set(road.id, lanes); byRoadId.set(road.id, lanes);
markingByRoadId.set(road.id, markingLanes);
} }
return { features, byRoadId }; for (const cluster of clusters) {
const clusterNodes = new Set(cluster.nodeIds.map(String));
const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
const compositeCenter = clusterCoordinates.length
? clusterCoordinates.reduce((sum, point) => [sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length], [0, 0])
: null;
const corridors = [];
for (const [nodeId, plan] of junctionPlans) {
if (!clusterNodes.has(String(nodeId))) continue;
for (const approach of plan.approaches) {
const end = approach.line.at(-1);
if ([...clusterNodes].some((candidate) => candidate !== String(nodeId) && distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3)) continue;
const heading = ((headingAtEndpoint(approach.line) + 180) % 360) - 180;
corridors.push({ nodeId, heading, approach, plan });
}
}
for (const corridor of corridors) {
const approach = corridor.approach; const plan = corridor.plan;
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const length = Math.min(distanceAlongLineToRadius(approach.line, compositeCenter, outerRadius), lineLengthMeters(approach.line));
if (length < 12) continue;
const line = approach.line; const outer = pointAlongLine(line, Math.max(0, length)); const inner = pointAlongLine(line, Math.min(Math.max(3, Number(cluster.coreRadiusMeters || 28) * .14), Math.max(3, length - 8)));
const corridorRoads = approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).filter(Boolean);
const incoming = corridorRoads.some((road) => String(road.sourceNodeIds.at(-1)) === String(corridor.nodeId));
const count = Math.max(1, corridorRoads.reduce((sum, road) => sum + road.laneCount, 0));
const laneWidth = approach.widthMeters / count;
const axis = project(inner, outer); const total = Math.hypot(...axis); if (!total) continue;
const normalized = [axis[0] / total, axis[1] / total]; const across = [-normalized[1], normalized[0]];
for (let index = 0; index < count; index += 1) {
const offset = approach.widthMeters / 2 - laneWidth * (index + .5);
const start = unproject([across[0] * offset, across[1] * offset], outer);
const end = unproject([across[0] * offset, across[1] * offset], inner);
const maneuver = incoming ? index === 0 ? "left" : index === count - 1 ? "right" : "through" : "outbound";
// Preview lanes are laid out radially from the outer radius inwards, so
// an untrimmed one runs straight over the arm crossing. Stop it at the
// first control: incoming lanes land on the stop bar, outgoing lanes on
// the far edge of the crossing.
const visible = trimLaneOutsideControls([start, end], controlFeatures, LANE_CENTERLINE_PROBE_WIDTH_METERS, compositeCenter);
features.push({ type: "Feature", properties: { native_id: `cluster-approach-lane:${cluster.id}:${approach.segmentId}:${index + 1}`, road_id: corridorRoads[0]?.id || null, cluster_id: cluster.id, cluster_preview: true, incoming, lane_index: index + 1, maneuver, control_clipped: laneWasClipped([start, end], visible), source: "native-road-junction-cluster-lane/v4-control-clipped" }, geometry: { type: "LineString", coordinates: visible || [start, end] } });
}
}
}
return { features, byRoadId, markingByRoadId };
}
function cubicTurnCurve(start, end, startHeading, endHeading, radius, turn, center) {
const reach = turn === "right" ? Math.max(5, radius * .75) : Math.max(9, radius * 1.35);
const first = offsetCoordinate(start, startHeading, reach);
const second = offsetCoordinate(end, endHeading, reach);
const points = [];
for (let index = 0; index <= 18; index += 1) {
const t = index / 18; const inverse = 1 - t;
points.push([
inverse ** 3 * start[0] + 3 * inverse ** 2 * t * first[0] + 3 * inverse * t ** 2 * second[0] + t ** 3 * end[0],
inverse ** 3 * start[1] + 3 * inverse ** 2 * t * first[1] + 3 * inverse * t ** 2 * second[1] + t ** 3 * end[1],
]);
}
return points.every((point) => point.every(Number.isFinite)) ? points : [start, center, end];
} }
function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) { function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) {
@@ -683,7 +959,8 @@ function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans)
const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus }; const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus };
if (length < .4) { movements.push(movement); continue; } if (length < .4) { movements.push(movement); continue; }
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; } if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; }
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } }); const cluster = plan?.clusterId || null;
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, cluster_id: cluster, cluster_internal: Boolean(cluster), from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } });
movements.push(movement); movements.push(movement);
} }
} }
@@ -793,9 +1070,11 @@ function polygonAreaMeters(ring) {
return Math.abs(twiceArea) / 2; return Math.abs(twiceArea) / 2;
} }
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) { function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics, options = {}) {
const result = []; const result = [];
const complexClusters = new Set((options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []).filter((cluster) => cluster.template === "complex-junction-v1").map((cluster) => cluster.id));
for (const [nodeId, plan] of junctionPlans) { for (const [nodeId, plan] of junctionPlans) {
if (plan.clusterId && complexClusters.has(plan.clusterId)) continue;
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan; const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId); const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId); const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
@@ -817,21 +1096,56 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
} }
const surfaceAreaMeters = polygonAreaMeters(ring); const surfaceAreaMeters = polygonAreaMeters(ring);
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null; const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } }); result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, template: plan.template || null, template_reference: plan.templateReference || null, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: plan.template ? "junction-cross-template/v1" : "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node)); if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
if (plan.boundaryFallbacks) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-rounded-corner-fallback", "部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。", node)); if (plan.boundaryFallbacks) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-rounded-corner-fallback", "部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。", node));
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node)); if (!plan.clusterId) diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
for (const cluster of options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []) {
if (cluster.template === "complex-junction-v1") continue;
const clusterNodes = new Set(cluster.nodeIds.map(String));
const members = result.filter((feature) => clusterNodes.has(String(feature.properties.osm_node_id)));
if (members.length < 2) continue;
const points = [];
const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
const clusterCenter = clusterCoordinates.reduce((sum, point) => [sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length], [0, 0]);
for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(clusterCenter, index * 45, 12));
for (const [nodeId, plan] of junctionPlans) {
if (!clusterNodes.has(String(nodeId))) continue;
for (const approach of plan.approaches) {
const end = approach.line.at(-1);
if ([...clusterNodes].some((candidate) => candidate !== String(nodeId) && distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3)) continue;
const cutback = pointAlongLine(approach.line, Math.min(plan.cutbackMeters, Math.max(12, cluster.approachLengthMeters * .5)));
const heading = headingAtEndpoint(approach.line); const half = approach.widthMeters / 2;
points.push(offsetCoordinate(cutback, heading + 90, half), offsetCoordinate(cutback, heading - 90, half));
}
const node = plan.node;
for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(node, index * 45, 9));
}
const hull = convexHull(points);
if (hull.length < 3) continue;
const ring = roundedHull(hull, 0.22);
const memberIds = new Set(members.map((feature) => feature.properties.native_id));
for (let index = result.length - 1; index >= 0; index -= 1) if (memberIds.has(result[index].properties.native_id)) result.splice(index, 1);
result.push({ type: "Feature", properties: { native_id: `junction-cluster:${cluster.id}`, osm_node_ids: [...clusterNodes].join(","), kind: "cluster", template: cluster.template, boundary_mode: "cluster-import-core", center: clusterCenter, member_count: members.length, movement_count: movements.filter((movement) => clusterNodes.has(String(movement.nodeId))).length, connector_count: connectors.filter((feature) => clusterNodes.has(String(feature.properties.node_id))).length, surface_area_m2: Math.round(polygonAreaMeters(ring) * 10) / 10, rule: "junction-cluster-template/v2" }, geometry: { type: "Polygon", coordinates: [[...ring, ring[0]]] } });
diagnostics.push(diagnostic("info", `junction-cluster:${cluster.id}`, [...clusterNodes], "junction-cluster-core-applied", "已按外部进口截面和簇节点核心生成受限复合路口面。", ring[0]));
} }
return result; return result;
} }
function compileJunctionPlans(model) { function activeComplexCluster(options, clusterId) {
return Boolean(clusterId && (options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []).some((cluster) => cluster.id === clusterId && cluster.template === "complex-junction-v1"));
}
function compileJunctionPlans(model, options = {}, diagnostics = []) {
const byNode = new Map(); const byNode = new Map();
for (const endpoint of model.endpoints) { for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint); byNode.get(endpoint.nodeId).push(endpoint);
} }
const plans = new Map(); const plans = new Map();
const clusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
for (const [nodeId, endpoints] of byNode) { for (const [nodeId, endpoints] of byNode) {
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, ""))); const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
if (segmentIds.size < 3 || segmentIds.size > 4) continue; if (segmentIds.size < 3 || segmentIds.size > 4) continue;
@@ -839,11 +1153,14 @@ function compileJunctionPlans(model) {
if (approaches.length !== segmentIds.size) continue; if (approaches.length !== segmentIds.size) continue;
// Rounded curb corners need enough approach length to retain the full // Rounded curb corners need enough approach length to retain the full
// turning envelope after the corner is cut toward the junction. // turning envelope after the corner is cut toward the junction.
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4; const baseCutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const node = endpoints[0].coordinate; const node = endpoints[0].coordinate;
const boundary = junctionBoundary(approaches, node, cutbackMeters); const template = junctionTemplateFor(nodeId, segmentIds, options.junctionTemplates, diagnostics, node);
const cutbackMeters = baseCutbackMeters * (template?.cutbackMultiplier || 1);
const boundary = junctionBoundary(approaches, node, cutbackMeters, template?.cornerRadiusMultiplier || 1, template?.approachWidthMultiplier || 1);
if (boundary.points.length < 3) continue; if (boundary.points.length < 3) continue;
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks }); const cluster = clusterByNode.get(String(nodeId));
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks, template: template?.template || null, templateReference: template?.referenceFile || null, approachWidthMultiplier: template?.approachWidthMultiplier || 1, approachLengthMeters: template?.approachLengthMeters || 0, clusterId: cluster?.id || null });
} }
return plans; return plans;
} }
@@ -863,13 +1180,13 @@ function junctionApproaches(model, endpoints) {
}); });
} }
function junctionBoundary(approaches, node, cutbackMeters) { function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) {
const points = []; const points = [];
for (const approach of approaches) { for (const approach of approaches) {
const cutback = pointAlongLine(approach.line, cutbackMeters); const cutback = pointAlongLine(approach.line, cutbackMeters);
if (!cutback) continue; if (!cutback) continue;
const heading = headingAtEndpoint(approach.line); const heading = headingAtEndpoint(approach.line);
const half = approach.widthMeters / 2; const half = approach.widthMeters * approachWidthMultiplier / 2;
points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading }); points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading }); points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
} }
@@ -886,7 +1203,7 @@ function junctionBoundary(approaches, node, cutbackMeters) {
// bends the far side of a T junction and exposes junction asphalt beyond // bends the far side of a T junction and exposes junction asphalt beyond
// the pedestrian strip. // the pedestrian strip.
if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue; if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue;
const curve = roundedCorner(node, first.point, second.point, first.outwardHeading, second.outwardHeading); const curve = roundedCorner(node, first.point, second.point, first.outwardHeading, second.outwardHeading, cornerRadiusMultiplier);
if (!curve) { fallbacks += 1; continue; } if (!curve) { fallbacks += 1; continue; }
boundary.push(...curve.slice(1, -1)); boundary.push(...curve.slice(1, -1));
rounded += 1; rounded += 1;
@@ -894,13 +1211,50 @@ function junctionBoundary(approaches, node, cutbackMeters) {
return { points: boundary, mode: rounded ? "rounded-approach-envelope" : "approach-envelope", fallbacks }; return { points: boundary, mode: rounded ? "rounded-approach-envelope" : "approach-envelope", fallbacks };
} }
function templateApproachRing(approach, plan) {
const innerDistance = plan.cutbackMeters;
const availableLength = lineLengthMeters(approach.line) - innerDistance - .5;
const lengthMeters = Math.min(plan.approachLengthMeters, availableLength);
if (lengthMeters < 10) return null;
const outerDistance = innerDistance + lengthMeters;
const inner = pointAlongLine(approach.line, innerDistance);
const outer = pointAlongLine(approach.line, outerDistance);
const heading = headingAtEndpoint(approach.line);
const innerHalf = approach.widthMeters * plan.approachWidthMultiplier / 2;
const outerHalf = approach.widthMeters / 2;
const ring = [
offsetCoordinate(outer, heading + 90, outerHalf),
offsetCoordinate(inner, heading + 90, innerHalf),
offsetCoordinate(inner, heading - 90, innerHalf),
offsetCoordinate(outer, heading - 90, outerHalf),
offsetCoordinate(outer, heading + 90, outerHalf),
];
return ring.every((point) => point.every(Number.isFinite)) ? { ring, lengthMeters } : null;
}
function roundedHull(hull, factor) {
const result = [];
for (let index = 0; index < hull.length; index += 1) {
const previous = hull[(index - 1 + hull.length) % hull.length];
const current = hull[index];
const next = hull[(index + 1) % hull.length];
const entry = interpolate(previous, current, factor);
const exit = interpolate(current, next, factor);
result.push(entry);
const curve = quadraticCurve(entry, current, exit, 4);
result.push(...curve.slice(1, -1));
result.push(exit);
}
return result;
}
function isStraightJunctionEdge(first, second) { function isStraightJunctionEdge(first, second) {
if (first.sourceWayKey !== second.sourceWayKey) return false; if (first.sourceWayKey !== second.sourceWayKey) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180; const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98; return Math.cos(radians) <= -0.98;
} }
function roundedCorner(node, first, second, firstHeading, secondHeading) { function roundedCorner(node, first, second, firstHeading, secondHeading, radiusMultiplier = 1) {
const origin = node; const origin = node;
const a = project(first, origin); const b = project(second, origin); const a = project(first, origin); const b = project(second, origin);
const chord = Math.hypot(a[0] - b[0], a[1] - b[1]); const chord = Math.hypot(a[0] - b[0], a[1] - b[1]);
@@ -915,10 +1269,51 @@ function roundedCorner(node, first, second, firstHeading, secondHeading) {
// node and the cutback. Reject near-parallel or remote intersections rather // node and the cutback. Reject near-parallel or remote intersections rather
// than publishing a huge/self-crossing curve. // than publishing a huge/self-crossing curve.
if (controlDistance < .01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null; if (controlDistance < .01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null;
const control = unproject(intersection, origin); const scaledIntersection = [intersection[0] * radiusMultiplier, intersection[1] * radiusMultiplier];
const control = unproject(scaledIntersection, origin);
return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS); return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS);
} }
function junctionTemplateFor(nodeId, segmentIds, configured, diagnostics, node) {
if (!configured?.enabled) return null;
const entry = (configured.references || []).find((item) => String(item.nodeId) === String(nodeId));
if (!entry) return null;
if (segmentIds.size !== 4) {
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "junction-template-topology-skip", "cross 模板只应用于四臂路口,当前路口保留 native 几何。", node));
return null;
}
if (entry.template !== "cross-v1") {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-unsupported", "路口模板名称不受支持,已回退 native 几何。", node));
return null;
}
const multiplier = Number(entry.cornerRadiusMultiplier ?? 1);
if (!Number.isFinite(multiplier) || multiplier < 0.75 || multiplier > 1.25) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板圆角参数必须在 0.75 到 1.25 之间,已回退 native 几何。", node));
return null;
}
const cutbackMultiplier = Number(entry.cutbackMultiplier ?? 1);
if (!Number.isFinite(cutbackMultiplier) || cutbackMultiplier < 1 || cutbackMultiplier > 1.35) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板进口过渡参数必须在 1 到 1.35 之间,已回退 native 几何。", node));
return null;
}
const approachWidthMultiplier = Number(entry.approachWidthMultiplier ?? 1);
if (!Number.isFinite(approachWidthMultiplier) || approachWidthMultiplier < 1 || approachWidthMultiplier > 1.8) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板进口宽度参数必须在 1 到 1.8 之间,已回退 native 几何。", node));
return null;
}
const approachLengthMeters = Number(entry.approachLengthMeters ?? 24);
if (!Number.isFinite(approachLengthMeters) || approachLengthMeters < 10 || approachLengthMeters > 50) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板进口过渡长度必须在 10 到 50 米之间,已回退 native 几何。", node));
return null;
}
if (entry.referenceFile && !fs.existsSync(entry.referenceFile)) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-reference-missing", "路口参考文件不存在,已回退 native 几何。", node));
return null;
}
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "junction-template-applied", "已按 cross-v1 模板规整路口面;道路、车道、连接器和控制设施保持 native 结果。", node));
return { template: entry.template, referenceFile: entry.referenceFile || null, cornerRadiusMultiplier: multiplier, cutbackMultiplier, approachWidthMultiplier, approachLengthMeters };
}
function headingVector(degrees) { function headingVector(degrees) {
const radians = degrees * Math.PI / 180; const radians = degrees * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)]; return [Math.sin(radians), Math.cos(radians)];
@@ -978,6 +1373,42 @@ function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
return result; return result;
} }
function trimLineAtComplexCluster(line, sourceNodeIds, junctionPlans, cluster, center) {
if (!center || line.length < 2) return trimLineAtJunctions(line, sourceNodeIds, junctionPlans);
const boundaryRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const startInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds[0]));
const endInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds.at(-1)));
const available = lineLengthMeters(line);
const startDistance = startInCluster ? distanceAlongLineToRadius(line, center, boundaryRadius) : 0;
const endDistance = endInCluster ? distanceAlongLineToRadius([...line].reverse(), center, boundaryRadius) : 0;
const startCutback = startDistance > 0 && available > startDistance + 1 ? startDistance : 0;
const endCutback = endDistance > 0 && available > endDistance + 1 ? endDistance : 0;
if (!startCutback && !endCutback) return line;
return trimLineRange(line, startCutback, endCutback);
}
function distanceAlongLineToRadius(line, center, radius) {
if (!center || line.length < 2) return 0;
const heading = headingAtEndpoint(line);
const vector = project(line[0], center);
const radians = heading * Math.PI / 180;
const startRadius = vector[0] * Math.sin(radians) + vector[1] * Math.cos(radians);
return Math.max(0, radius - startRadius);
}
function trimLineRange(line, startCutback, endCutback) {
const total = lineLengthMeters(line);
if (startCutback + endCutback >= total - .5) return line;
const result = [pointAlongLine(line, startCutback)];
let traversed = 0;
for (let index = 1; index < line.length - 1; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
}
result.push(pointAlongLine(line, total - endCutback));
return result;
}
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); } function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; } function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; } function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }

View File

@@ -12,10 +12,26 @@ const {
const SCHEMA = "native-traffic-signals/v1"; const SCHEMA = "native-traffic-signals/v1";
function loadOrGenerate(file, osmText, stopLines, intersections) { function loadOrGenerate(file, osmText, stopLines, intersections) {
if (fs.existsSync(file)) return validateDocument(JSON.parse(fs.readFileSync(file, "utf8")), osmText); if (fs.existsSync(file)) {
const document = JSON.parse(fs.readFileSync(file, "utf8"));
try {
return validateDocument(document, osmText);
} catch (error) {
// OSM edits can invalidate the stable identities in a document that was
// itself generated from OSM. User-authored documents must remain strict.
if (document?.provenance === "generated:osm-controls" && isStaleSourceReferenceError(error)) {
return generate(osmText, stopLines, intersections);
}
throw error;
}
}
return generate(osmText, stopLines, intersections); return generate(osmText, stopLines, intersections);
} }
function isStaleSourceReferenceError(error) {
return error instanceof Error && /^traffic signal feature \d+: (approach_id .* is not present on OSM control|control_id .* is not present in the current OSM)/.test(error.message);
}
function generate(osmText, stopLines, intersections) { function generate(osmText, stopLines, intersections) {
const controls = parseOsm(osmText).trafficSignalControls; const controls = parseOsm(osmText).trafficSignalControls;
return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) }; return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) };

View File

@@ -23,20 +23,38 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
const point = polygonCenter(feature.geometry); const point = polygonCenter(feature.geometry);
return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) }; return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) };
}).filter((entry) => entry.point); }).filter((entry) => entry.point);
const clusteredStops = new Map();
for (const feature of stopLines.features || []) {
const clusterId = feature.properties?.cluster_id;
const point = polygonCenter(feature.geometry);
if (!clusterId || !point) continue;
if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []);
clusteredStops.get(clusterId).push(point);
}
for (const [clusterId, points] of clusteredStops) {
if (points.length < 3) continue;
const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]);
centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) });
}
const candidates = []; const candidates = [];
for (const feature of stopLines.features || []) { for (const feature of stopLines.features || []) {
const center = polygonCenter(feature.geometry); const center = polygonCenter(feature.geometry);
if (!center) continue; if (!center) continue;
const intersection = nearestCenter(center, centers); const clusterId = feature.properties?.cluster_id;
const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers);
if (!intersection || metersBetween(center, intersection.point) > 32) continue; if (!intersection || metersBetween(center, intersection.point) > 32) continue;
const axis = roadAxis(feature.geometry, center, intersection.point); const axis = roadAxis(feature.geometry, center, intersection.point);
if (!axis) continue; if (!axis) continue;
const right = [axis[1], -axis[0]]; const right = [axis[1], -axis[0]];
const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2);
candidates.push({ candidates.push({
intersectionId: intersection.id, center, axis, intersectionId: intersection.id, center, axis,
point: moveMeters(farSide, right, CURB_OFFSET_METERS), point: intersection.clusterId
? moveMeters(center, right, CURB_OFFSET_METERS)
: moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS),
headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI),
matchHeadingDegrees: intersection.clusterId
? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI)
: null,
}); });
} }
const features = []; const features = [];
@@ -180,25 +198,34 @@ function signalNodeKey(signalUid) {
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`; return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
} }
function validateTrafficSignalSourceReferences(collection, controls) { function reconcileTrafficSignalSourceReferences(collection, controls) {
const normalized = validateTrafficSignalFeatures(collection); const normalized = validateTrafficSignalFeatures(collection);
const approachesByControl = new Map((controls || []).map((control) => [ const approachesByControl = new Map((controls || []).map((control) => [
String(control.id), String(control.id),
new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)), new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)),
])); ]));
const kept = [];
const dropped = [];
for (const [index, feature] of normalized.features.entries()) { for (const [index, feature] of normalized.features.entries()) {
const { control_id: controlId, approach_id: approachId } = feature.properties; const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties;
const approaches = approachesByControl.get(controlId); const approaches = approachesByControl.get(controlId);
if (!approaches) { if (!approaches) {
throw new Error(`traffic signal feature ${index + 1}: control_id '${controlId}' is not present in the current OSM`); dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` });
continue;
} }
if (!approaches.has(approachId)) { if (!approaches.has(approachId)) {
throw new Error( dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` });
`traffic signal feature ${index + 1}: approach_id '${approachId}' is not present on OSM control '${controlId}'`, continue;
);
} }
kept.push(feature);
} }
return normalized; return { collection: { ...normalized, features: kept }, dropped };
}
function validateTrafficSignalSourceReferences(collection, controls) {
const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls);
if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`);
return reconciled;
} }
function buildTrafficSignals(stopLines, intersections, controls = []) { function buildTrafficSignals(stopLines, intersections, controls = []) {
@@ -237,11 +264,20 @@ function uniqueApproachArms(candidates, controlPoint) {
} }
function matchOsmArms(candidates, controlPoint, osmArms) { function matchOsmArms(candidates, controlPoint, osmArms) {
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)) })); const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint); if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint);
return osmArms.map((osmArm) => { return osmArms.map((osmArm) => {
let bestIndex = -1; let bestDistance = Infinity; let bestIndex = -1; let bestDistance = Infinity;
remaining.forEach((item, index) => { const distance = angularDistance(item.armHeading, osmArm.headingDegrees); if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } }); remaining.forEach((item, index) => {
const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees);
const distance = item.matchHeadingDegrees == null
? directedDistance
: Math.min(
angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees),
angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees),
);
if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
});
const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm); const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm);
return { ...candidate, osmArm }; return { ...candidate, osmArm };
}); });

View File

@@ -4,10 +4,12 @@
const fs = require("fs"); const fs = require("fs");
const http = require("http"); const http = require("http");
const path = require("path"); const path = require("path");
const { execFileSync } = require("child_process");
const { readAreaConfig } = require("./lib/area-config"); const { readAreaConfig } = require("./lib/area-config");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road"); const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals"); const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals");
const { compileArea, parseArgs } = require("./compile-native-roads"); const { compileArea, parseArgs } = require("./compile-native-roads");
const { convertGeoJson } = require("./lib/gaode-junction-reference");
const repoRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(__dirname, "..");
@@ -16,9 +18,10 @@ function main() {
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json")); const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
if (args.noCompile !== "true") compileArea(configPath); if (args.noCompile !== "true") compileArea(configPath);
const area = readAreaConfig(configPath, { repoRoot }); const area = readAreaConfig(configPath, { repoRoot });
const junctionReference = args.junctionReference ? readJunctionReference(path.resolve(args.junctionReference)) : null;
const port = Number(args.port || 8787); const port = Number(args.port || 8787);
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535]."); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const server = http.createServer((request, response) => handle(request, response, area, configPath)); const server = http.createServer((request, response) => handle(request, response, area, configPath, junctionReference));
server.on("error", (error) => { server.on("error", (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`); console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1; process.exitCode = 1;
@@ -26,13 +29,13 @@ function main() {
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/`)); server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/`));
} }
function handle(request, response, area, configPath) { function handle(request, response, area, configPath, junctionReference) {
const url = new URL(request.url, "http://127.0.0.1"); const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8"); if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8"); if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8"); if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname); if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area)); if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference));
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => { if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document); writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
@@ -54,20 +57,37 @@ function handle(request, response, area, configPath) {
sendJson(response, 200, { ok: true, overrides }); sendJson(response, 200, { ok: true, overrides });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => { if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
compileArea(configPath); compileFresh(configPath);
sendJson(response, 200, state(area)); sendJson(response, 200, state(area, junctionReference));
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message })); }).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
sendJson(response, 404, { error: "Not found" }); sendJson(response, 404, { error: "Not found" });
} }
function state(area) { function state(area, junctionReference = null) {
const nativeDir = area.outputs.nativeRoadDir; const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson"); const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals) const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")) ? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }; : { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
const trafficRuntime = runtime(trafficSignals); const trafficRuntime = runtime(trafficSignals);
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } }; return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
}
function compileFresh(configPath) {
try {
return execFileSync(process.execPath, [path.join(repoRoot, "scripts", "compile-native-roads.js"), "--config", configPath], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
const detail = String(error.stderr || error.stdout || error.message || "native compilation failed").trim();
throw new Error(`Native road compilation failed: ${detail}`);
}
}
function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8")));
return { source: file, coordinateSystem: "GCJ-02", converted };
} }
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); } function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { gcj02ToWgs84, convertGeoJson, inspectReference } = require("./lib/gaode-junction-reference");
const converted = gcj02ToWgs84([114.12864875054062, 30.460485279762146]);
assert.ok(Math.abs(converted[0] - 114.1229659) < 0.00001);
assert.ok(Math.abs(converted[1] - 30.4628266) < 0.00001);
assert.throws(() => gcj02ToWgs84([Infinity, 30]), /finite/);
assert.deepEqual(convertGeoJson({ type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [114.12864875054062, 30.460485279762146] } }] }).features[0].geometry.coordinates.map((value) => Number(value.toFixed(6))), [114.122966, 30.462827]);
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "gaode-junction-reference-"));
try {
const reference = path.join(temp, "reference.geojson");
const osm = path.join(temp, "input.osm");
const native = path.join(temp, "intersections.geojson");
fs.writeFileSync(reference, JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { type: 1 }, geometry: { type: "Polygon", coordinates: [[[114.12860, 30.46040], [114.12870, 30.46040], [114.12870, 30.46050], [114.12860, 30.46040]]] } }] }));
fs.writeFileSync(osm, "<osm><node id='8005332807' lon='114.1229249' lat='30.462899'><tag k='highway' v='traffic_signals'/></node></osm>");
fs.writeFileSync(native, JSON.stringify({ type: "FeatureCollection", features: [{ type: "Feature", properties: { osm_node_id: "8005332807" }, geometry: { type: "Polygon", coordinates: [[[114.1228, 30.4627], [114.1231, 30.4627], [114.1231, 30.4630], [114.1228, 30.4627]]] } }] }));
const result = inspectReference({ referenceFile: reference, osmFile: osm, nativeIntersectionFile: native, nodeId: "8005332807" });
assert.equal(result.matchedOsmNode.id, "8005332807");
assert.equal(result.matchedOsmNode.match, "node-id");
assert.ok(result.matchedOsmNode.centerDistanceMeters < 20);
assert.ok(result.nativeIntersection.bboxIoU > 0);
assert.deepEqual(result.diagnostics, []);
fs.writeFileSync(native, JSON.stringify({ type: "FeatureCollection", features: [] }));
assert.match(inspectReference({ referenceFile: reference, osmFile: osm, nativeIntersectionFile: native, nodeId: "8005332807" }).diagnostics[0], /No native intersection/);
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}
console.log("Gaode junction reference tests passed.");

View File

@@ -73,7 +73,7 @@ assert.ok(geometry.connectors.features.every((feature) => feature.properties.nod
assert.ok(geometry.movements.length >= geometry.connectors.features.length); assert.ok(geometry.movements.length >= geometry.connectors.features.length);
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:"))); assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus))); assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3")); assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v4-shared-node-split"));
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode))); assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1)); assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback")); for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
@@ -117,6 +117,180 @@ assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.prop
assert.ok(crossGeometry.roadSurface.features.every((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 4)); assert.ok(crossGeometry.roadSurface.features.every((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 4));
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]); const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5)); assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
const crossTemplateGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.1 }] },
});
assert.equal(crossTemplateGeometry.intersectionSurface.features[0].properties.template, "cross-v1");
assert.equal(crossTemplateGeometry.intersectionSurface.features[0].properties.rule, "junction-cross-template/v1");
assert.ok(crossTemplateGeometry.diagnostics.some((item) => item.rule === "junction-template-applied"));
assert.notDeepEqual(crossTemplateGeometry.intersectionSurface.features[0].geometry.coordinates, crossGeometry.intersectionSurface.features[0].geometry.coordinates);
assert.deepEqual(crossTemplateGeometry.connectors, crossGeometry.connectors);
assert.deepEqual(crossTemplateGeometry.vehicleStopLines, crossGeometry.vehicleStopLines);
const crossCutbackTemplateGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.25, cutbackMultiplier: 1.25 }] },
});
const crossCutbackSurface = crossCutbackTemplateGeometry.intersectionSurface.features[0];
assert.ok(crossCutbackSurface.properties.cutback_m > crossGeometry.intersectionSurface.features[0].properties.cutback_m);
assert.ok(crossCutbackSurface.properties.surface_area_m2 > crossGeometry.intersectionSurface.features[0].properties.surface_area_m2);
const crossApproachTemplateGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.25, cutbackMultiplier: 1.25, approachWidthMultiplier: 1.45, approachLengthMeters: 24 }] },
});
const templateApproaches = crossApproachTemplateGeometry.roadSurface.features.filter((feature) => feature.properties.template === "cross-v1");
assert.equal(templateApproaches.length, 4);
assert.ok(templateApproaches.every((feature) => feature.properties.approach_width_m > feature.properties.width_m && feature.properties.approach_length_m === 24));
const clusterApproachGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
junctionTemplates: { enabled: true, references: [], clusters: [{ id: "cross-cluster", nodeIds: ["2", "missing"], template: "complex-junction-v1", approachWidthMultiplier: 1.45, approachLengthMeters: 24, coreRadiusMeters: 18 }] },
});
const clusterApproaches = clusterApproachGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === "cross-cluster");
assert.equal(clusterApproaches.filter((feature) => feature.properties.kind === "complex-reference-surface" || feature.properties.kind === "complex-composite").length, 0);
assert.ok(clusterApproachGeometry.diagnostics.some((item) => item.rule === "complex-junction-insufficient-nodes"));
assert.equal(clusterApproachGeometry.intersectionSurface.features.length, 0);
const fengshuOsm = fs.readFileSync(path.join(__dirname, "..", "inputs", "osm", "枫树二路.osm"), "utf8");
const fengshuModel = compileRoadModel(fengshuOsm, empty);
const fengshuCluster = {
id: "zhushanhu-fengshu-complex",
template: "complex-junction-v1",
referenceFile: path.join(__dirname, "..", "inputs", "osm", "珠山湖大道(枫树二路)口.geojson"),
approachWidthMultiplier: 1.45,
approachLengthMeters: 32,
coreRadiusMeters: 28,
outerRadiusExtraMeters: 18,
nodeIds: ["8005332807", "8024512135", "8024512145", "8024512147"],
};
const fengshuGeometry = compileGeometry(fengshuModel, empty, { edgeLines: false, junctionTemplates: { enabled: true, references: [], clusters: [fengshuCluster] } });
const fengshuSidewalkOverrides = { schema: "native-road-overrides/v1", overrides: [
{ id: "generic-complex-sidewalk-a", kind: "road", roadId: "road:way/99505317:forward", sidewalkRight: true },
{ id: "generic-complex-sidewalk-b", kind: "road", roadId: "road:way/858770821:segment/2:forward", sidewalkLeft: true },
] };
const fengshuSidewalkGeometry = compileGeometry(compileRoadModel(fengshuOsm, fengshuSidewalkOverrides), fengshuSidewalkOverrides, { edgeLines: false, junctionTemplates: { enabled: true, references: [], clusters: [fengshuCluster] } });
const fengshuConfiguredSidewalks = fengshuSidewalkGeometry.sidewalkSurface.features.filter((feature) => feature.properties.provenance === "native-road-sidewalk/v1" && feature.properties.cluster_id === fengshuCluster.id);
assert.equal(fengshuConfiguredSidewalks.length, 2, "sidewalk overrides on complex approaches remain road-side features");
const fengshuConfiguredCore = fengshuSidewalkGeometry.roadSurface.features.find((feature) => feature.properties.kind === "complex-core");
assert.ok(fengshuConfiguredSidewalks.every((feature) => !ringsOverlap(feature.geometry.coordinates[0], fengshuConfiguredCore.geometry.coordinates[0])), "configured sidewalks stop at the complex-junction handoff instead of entering its core");
assert.equal(fengshuSidewalkGeometry.sidewalkSurface.features.filter((feature) => feature.properties.provenance === "native-road-sidewalk-corner/v1" && fengshuCluster.nodeIds.includes(String(feature.properties.osm_node_id))).length, 0, "ordinary sidewalk corners are not generated at member nodes of a complex junction");
const genericComplexSource = fs.readFileSync(path.join(__dirname, "lib", "complex-junction.js"), "utf8");
assert.doesNotMatch(genericComplexSource, /8005332807|8024512135|8024512145|8024512147|858770823|珠山湖|枫树二路/, "complex junction generator must not contain sample-specific identifiers");
const renamedCluster = { ...fengshuCluster, id: "generic-complex-validation-cluster" };
const renamedGeometry = compileGeometry(fengshuModel, empty, { edgeLines: false, junctionTemplates: { enabled: true, references: [], clusters: [renamedCluster] } });
assert.equal(renamedGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === renamedCluster.id && feature.properties.kind === "complex-core").length, 1, "complex geometry is selected by template and topology, not cluster name");
assert.equal(renamedGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === renamedCluster.id && feature.properties.kind === "complex-approach").length, 8);
assert.equal(renamedGeometry.laneCenterlines.features.filter((feature) => feature.properties.cluster_id === renamedCluster.id && feature.properties.cluster_preview).length, 24);
assert.equal(renamedGeometry.connectors.features.length, fengshuGeometry.connectors.features.length, "renaming a complex cluster does not change native connector topology");
const fengshuRoadParts = fengshuGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind);
assert.equal(fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-core").length, 1);
assert.equal(fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-approach").length, 8);
const fengshuCore = fengshuRoadParts.find((feature) => feature.properties.kind === "complex-core");
assert.equal(fengshuCore.properties.corner_rounding_ratio, .16, "complex core rounds its four road-corner transitions without adding exterior sidewalk platforms");
assert.equal(fengshuCore.geometry.coordinates[0].length, 33, "complex core samples a rounded boundary at each of its approach-edge corners");
const fengshuApproachOuterExtents = fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-approach").map((feature) => {
const extent = radialExtent(feature, fengshuCore.properties.center, feature.properties.heading_deg);
return extent[1];
});
assert.equal(fengshuApproachOuterExtents.filter((extent) => Math.abs(extent - (fengshuCore.properties.radius_m + 18)) < .15).length, 7, "all full-length complex approaches reach the same outer handoff radius");
assert.ok(fengshuApproachOuterExtents.every((extent) => extent <= fengshuCore.properties.radius_m + 18.15), "short OSM approaches stop at their continuation node instead of overshooting it");
const fengshuCornerFillets = fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-corner-fillet");
assert.equal(fengshuCornerFillets.length, 4, "every adjacent-arm corner of a complex junction gets a curb fillet");
assert.deepEqual(fengshuCornerFillets.map((feature) => feature.properties.corner_index).sort(), [1, 2, 3, 4]);
assert.ok(fengshuCornerFillets.every((feature) => feature.properties.corner_radius_m === 12), "corner fillets use the configured curb radius");
// The fillet has to stay between the core it smooths and the handoff radius
// where the arms become ordinary road surface; a fillet reaching past either
// bound would cut the junction open or bridge across the carriageways.
assert.ok(fengshuCornerFillets.every((feature) => {
const extent = radialExtent(feature, fengshuCore.properties.center, feature.properties.bisector_heading);
return extent[1] > fengshuCore.properties.radius_m * .5 && extent[1] < fengshuCore.properties.radius_m + 18;
}), "corner fillets fill the wedge between the complex core and the approach handoff radius");
assert.ok(fengshuCornerFillets.every((feature) => !ringSelfIntersects(feature.geometry.coordinates[0])), "corner fillet rings are simple polygons");
const fengshuBoundaryRoadSurfaces = fengshuGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && !feature.properties.kind);
assert.equal(fengshuBoundaryRoadSurfaces.length, 7, "complex junction keeps its OSM-derived exterior road surfaces");
const fengshuPreviewLanes = fengshuGeometry.laneCenterlines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.cluster_preview);
assert.equal(fengshuPreviewLanes.length, 24);
assert.equal(fengshuPreviewLanes.filter((feature) => feature.properties.incoming).length, 12);
assert.equal(fengshuPreviewLanes.filter((feature) => feature.properties.maneuver === "outbound").length, 12);
const fengshuBoundaryLanes = fengshuGeometry.laneCenterlines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.cluster_boundary_clipped);
assert.equal(fengshuBoundaryLanes.length, 24, "eight exterior three-lane carriageways remain visible outside the complex junction boundary");
assert.ok(fengshuBoundaryLanes.every((feature) => !feature.properties.cluster_preview_hidden && !feature.properties.cluster_internal));
assert.ok(fengshuPreviewLanes.every((feature) => {
const road = fengshuModel.roads.find((candidate) => candidate.id === feature.properties.road_id);
const laneHeading = bearingDegrees(feature.geometry.coordinates[0], feature.geometry.coordinates.at(-1));
const roadHeading = bearingDegrees(road.centerline[0], road.centerline.at(-1));
return axialHeadingDifference(laneHeading, roadHeading) < 3;
}), "complex-junction lane centerlines remain parallel to their source OSM carriageways");
assert.equal(fengshuGeometry.laneSeparators.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.provenance === "native-road-complex-lane-separator/v1").length, 16);
const fengshuComplexControls = [
...fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id),
...fengshuGeometry.vehicleStopLines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id),
];
assert.equal(fengshuGeometry.laneSeparators.features
.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.provenance === "native-road-complex-lane-separator/v1").length, 16, "complex lane separators remain present after control-line clipping");
const fengshuArmCrosswalks = fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-crosswalk");
assert.ok(fengshuArmCrosswalks.length > 24);
assert.equal(new Set(fengshuArmCrosswalks.map((feature) => feature.properties.direction.toFixed(3))).size, 4, "four arm crosswalk groups define the central frame sides");
assert.ok(fengshuArmCrosswalks.every((feature) => {
const road = fengshuModel.roads.find((candidate) => candidate.id === feature.properties.road_id);
const stripe = feature.geometry.coordinates[0];
const stripeHeading = bearingDegrees(stripe[0], stripe[1]);
const roadHeading = bearingDegrees(road.centerline[0], road.centerline.at(-1));
return axialHeadingDifference(stripeHeading, roadHeading) < 3
&& distance(stripe[0], stripe[1]) > distance(stripe[1], stripe[2]) * 5;
}), "each arm crosswalk stripe is long and axially parallel to its source OSM carriageway");
assert.equal(fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-corner-crosswalk").length, 24);
assert.equal(fengshuGeometry.vehicleStopLines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id).length, 4);
const fengshuStopApproachArrows = fengshuGeometry.directionArrows.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.provenance === "native-road-complex-preview-arrow/v2-stop-anchored");
assert.equal(new Set(fengshuStopApproachArrows.map((feature) => feature.properties.lane_id)).size, 12, "each of the four complex approaches gets one maneuver arrow per generated lane");
assert.deepEqual([...new Set(fengshuStopApproachArrows.map((feature) => feature.properties.maneuver))].sort(), ["left", "right", "through"], "complex approach arrows preserve lane maneuver assignments");
assert.ok(fengshuStopApproachArrows.every((feature) => feature.properties.placement_distance_from_stop_meters === 8), "complex approach arrows are anchored upstream of their stop lines");
assert.ok(fengshuStopApproachArrows.every((feature) => {
const lane = fengshuGeometry.laneCenterlines.features.find((candidate) => candidate.properties.native_id === feature.properties.lane_id);
return lane && Math.abs(feature.properties.travel_heading_deg - bearingDegrees(lane.geometry.coordinates[0], lane.geometry.coordinates.at(-1))) < .01;
}), "complex stop-approach arrows retain the lane travel heading instead of the reversed placement axis");
assert.equal(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-median").length, 0, "complex junctions do not add center green belts");
assert.equal(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-corner-island").length, 4);
assert.equal(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-sidewalk-corner").length, 0, "complex junctions do not add center sidewalk/green-belt links");
assert.ok(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id).every((feature) => !ringSelfIntersects(feature.geometry.coordinates[0])));
const fengshuCenter = fengshuRoadParts.find((feature) => feature.properties.kind === "complex-core").properties.center;
for (let cornerIndex = 1; cornerIndex <= 4; cornerIndex += 1) {
const island = fengshuGeometry.sidewalkSurface.features.find((feature) => feature.properties.kind === "complex-corner-island" && feature.properties.corner_index === cornerIndex);
const crossing = fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.kind === "complex-corner-crosswalk" && feature.properties.corner_index === cornerIndex);
const heading = crossing[0].properties.direction;
const firstArm = groupedArmCrosswalk(fengshuArmCrosswalks, crossing[0].properties.from_heading);
const secondArm = groupedArmCrosswalk(fengshuArmCrosswalks, crossing[0].properties.to_heading);
const expectedFrameCorner = supportLineIntersection(featureGroupCenter(firstArm), headingForArmGroup(firstArm) + 90, featureGroupCenter(secondArm), headingForArmGroup(secondArm) + 90, fengshuCenter);
assert.ok(expectedFrameCorner, `corner ${cornerIndex} adjacent arm frame supports intersect`);
assert.ok(distance(featureGroupCenter(crossing), expectedFrameCorner) > 3.1 && distance(featureGroupCenter(crossing), expectedFrameCorner) < 3.7, `corner ${cornerIndex} diagonal crossing leaves compact room for a safety island beyond the frame corner`);
assert.ok(distance(crossing[0].properties.frame_corner, expectedFrameCorner) < .15, `corner ${cornerIndex} records its derived frame corner`);
assert.ok(distance(island.properties.frame_corner, expectedFrameCorner) < .15, `corner ${cornerIndex} safety island occupies its derived frame corner`);
const islandExtent = radialExtent(island, fengshuCenter, heading);
const crossingExtent = radialExtent(crossing, fengshuCenter, heading);
assert.ok(crossing.every((feature) => axialHeadingDifference(bearingDegrees(feature.geometry.coordinates[0][0], feature.geometry.coordinates[0][1]), heading) > 75), `corner ${cornerIndex} stripes remain transverse to the pedestrian path`);
const armVertices = [...firstArm, ...secondArm].flatMap((feature) => feature.geometry.coordinates[0]);
assert.ok(island.properties.base_points.every((point) => Math.min(...armVertices.map((vertex) => distance(point, vertex))) < .4), `corner ${cornerIndex} safety island base follows both long-crosswalk endpoints`);
assert.ok(island.properties.base_width_m > 6 && island.properties.base_width_m < 12, `corner ${cornerIndex} safety island fills the space left between two road-clipped crosswalks`);
assert.ok(island.properties.crossing_clearance_m >= .15 && island.properties.crossing_clearance_m <= .25, `corner ${cornerIndex} safety island fills the gap without extending through the diagonal crossing`);
assert.equal(island.geometry.coordinates[0].length, 7, `corner ${cornerIndex} safety island rounds the existing gap boundary without adding an outer platform`);
assert.ok(islandExtent[1] + .25 < crossingExtent[0], `corner ${cornerIndex} crossing starts beyond its safety island`);
}
for (const heading of [...new Set(fengshuArmCrosswalks.map((feature) => feature.properties.direction))]) {
const crossing = fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.kind === "complex-crosswalk" && Math.abs(feature.properties.direction - heading) < 1);
const stopLine = fengshuGeometry.vehicleStopLines.features.filter((feature) => Math.abs(feature.properties.direction - heading) < 2);
assert.ok(crossing.every((feature) => feature.properties.span_m > 20 && feature.properties.span_m <= feature.properties.road_envelope_span_m), "arm crosswalk spans the two three-lane carriageways without exceeding the OSM road envelope");
assert.ok(crossing.every((feature) => Math.abs(feature.properties.road_envelope_span_m - feature.properties.span_m - .7) < .05), "arm crosswalk keeps a curb inset on both road edges");
const lateralSpan = lateralExtent(crossing, fengshuCenter, heading);
assert.ok(lateralSpan[1] - lateralSpan[0] > crossing[0].properties.span_m - 1, "arm crosswalk geometry spans both three-lane carriageways and their median gap");
assert.ok(radialExtent(crossing, fengshuCenter, heading)[1] + .5 < radialExtent(stopLine, fengshuCenter, heading)[0], "incoming stop line is beyond the arm crosswalk");
assert.ok(radialExtent(stopLine, fengshuCenter, heading)[0] - radialExtent(crossing, fengshuCenter, heading)[1] < 1.2, "incoming stop line stays close to the arm crosswalk");
}
assert.deepEqual(crossCutbackTemplateGeometry.connectors.features.map((feature) => feature.properties.native_id).sort(), crossGeometry.connectors.features.map((feature) => feature.properties.native_id).sort());
assert.deepEqual(crossCutbackTemplateGeometry.movements.map((movement) => movement.id).sort(), crossGeometry.movements.map((movement) => movement.id).sort());
const missingReferenceGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", referenceFile: "/tmp/native-road-missing-junction-reference.geojson", cornerRadiusMultiplier: 1.1 }] },
});
assert.equal(missingReferenceGeometry.intersectionSurface.features[0].properties.template, null);
assert.ok(missingReferenceGeometry.diagnostics.some((item) => item.rule === "junction-template-reference-missing"));
const tTemplateGeometry = compileGeometry(compileRoadModel(osm, empty), empty, {
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.1 }] },
});
assert.ok(tTemplateGeometry.intersectionSurface.features.every((feature) => feature.properties.template === null));
const crossSidewalkCorners = crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner"); const crossSidewalkCorners = crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner");
assert.equal(crossSidewalkCorners.length, 4); assert.equal(crossSidewalkCorners.length, 4);
// A rounded sidewalk corner must sample both the curb and outer boundaries. // A rounded sidewalk corner must sample both the curb and outer boundaries.
@@ -217,6 +391,67 @@ try {
function distance(a, b) { function distance(a, b) {
return Math.hypot((b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180), (b[1] - a[1]) * 111320); return Math.hypot((b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180), (b[1] - a[1]) * 111320);
} }
function bearingDegrees(first, second) {
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180);
const north = second[1] - first[1];
return Math.atan2(east, north) * 180 / Math.PI;
}
function axialHeadingDifference(first, second) {
const difference = Math.abs(((first - second + 180) % 360 + 360) % 360 - 180);
return Math.min(difference, 180 - difference);
}
function ringSelfIntersects(ring) {
const orientation = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
for (let first = 0; first < ring.length - 1; first += 1) {
for (let second = first + 2; second < ring.length - 1; second += 1) {
if (first === 0 && second === ring.length - 2) continue;
if (orientation(ring[first], ring[first + 1], ring[second]) * orientation(ring[first], ring[first + 1], ring[second + 1]) < 0
&& orientation(ring[second], ring[second + 1], ring[first]) * orientation(ring[second], ring[second + 1], ring[first + 1]) < 0) return true;
}
}
return false;
}
function radialExtent(features, center, heading) {
const list = Array.isArray(features) ? features : [features];
const radians = heading * Math.PI / 180;
const values = list.flatMap((feature) => feature.geometry.coordinates[0].map((point) => {
const east = (point[0] - center[0]) * 111320 * Math.cos(center[1] * Math.PI / 180);
const north = (point[1] - center[1]) * 111320;
return east * Math.sin(radians) + north * Math.cos(radians);
}));
return [Math.min(...values), Math.max(...values)];
}
function lateralExtent(features, center, heading) {
return radialExtent(features, center, heading + 90);
}
function groupedArmCrosswalk(features, heading) {
return features.filter((feature) => directedHeadingDifference(feature.properties.direction, heading) < 1);
}
function directedHeadingDifference(first, second) {
return Math.abs(((first - second + 180) % 360 + 360) % 360 - 180);
}
function headingForArmGroup(features) {
return features[0].properties.direction;
}
function featureGroupCenter(features) {
const centers = features.map((feature) => {
const ring = feature.geometry.coordinates[0].slice(0, -1);
return ring.reduce((sum, point) => [sum[0] + point[0] / ring.length, sum[1] + point[1] / ring.length], [0, 0]);
});
return centers.reduce((sum, point) => [sum[0] + point[0] / centers.length, sum[1] + point[1] / centers.length], [0, 0]);
}
function supportLineIntersection(firstPoint, firstHeading, secondPoint, secondHeading, origin) {
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
const local = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
const direction = (heading) => [Math.sin(heading * Math.PI / 180), Math.cos(heading * Math.PI / 180)];
const first = local(firstPoint); const second = local(secondPoint);
const a = direction(firstHeading); const b = direction(secondHeading);
const denominator = a[0] * b[1] - a[1] * b[0];
if (Math.abs(denominator) < 1e-6) return null;
const delta = [second[0] - first[0], second[1] - first[1]];
const along = (delta[0] * b[1] - delta[1] * b[0]) / denominator;
return [origin[0] + (first[0] + a[0] * along) / lonScale, origin[1] + (first[1] + a[1] * along) / 111320];
}
function ringsOverlap(first, second) { function ringsOverlap(first, second) {
const bounds = (ring) => [Math.min(...ring.map((point) => point[0])), Math.min(...ring.map((point) => point[1])), Math.max(...ring.map((point) => point[0])), Math.max(...ring.map((point) => point[1]))]; const bounds = (ring) => [Math.min(...ring.map((point) => point[0])), Math.min(...ring.map((point) => point[1])), Math.max(...ring.map((point) => point[0])), Math.max(...ring.map((point) => point[1]))];
const a = bounds(first); const b = bounds(second); const a = bounds(first); const b = bounds(second);

View File

@@ -8,6 +8,7 @@ const path = require("path");
const html = fs.readFileSync(path.join(__dirname, "workbench", "index.html"), "utf8"); const html = fs.readFileSync(path.join(__dirname, "workbench", "index.html"), "utf8");
assert.match(html, /id="width" type="number" min="1" step="0\.01"/); assert.match(html, /id="width" type="number" min="1" step="0\.01"/);
assert.match(html, /data-layer="sidewalks" type="checkbox" checked> 路缘与步行带/); assert.match(html, /data-layer="sidewalks" type="checkbox" checked> 路缘与步行带/);
assert.match(html, /data-layer="gaodeReference" type="checkbox" checked> 高德规整路口参考/);
assert.match(html, /id="scene-preview" type="checkbox"/); assert.match(html, /id="scene-preview" type="checkbox"/);
assert.match(html, /id="selected-junction" hidden/); assert.match(html, /id="selected-junction" hidden/);
const app = fs.readFileSync(path.join(__dirname, "workbench", "app.js"), "utf8"); const app = fs.readFileSync(path.join(__dirname, "workbench", "app.js"), "utf8");
@@ -18,7 +19,13 @@ assert.match(app, /road\.segmentId === selectedRoad\.segmentId/);
assert.match(app, /sidewalkLeft: rightInput\.checked, sidewalkRight: leftInput\.checked/); assert.match(app, /sidewalkLeft: rightInput\.checked, sidewalkRight: leftInput\.checked/);
assert.match(app, /function nativeSurfaceStyle\(feature\)/); assert.match(app, /function nativeSurfaceStyle\(feature\)/);
assert.match(app, /reference: new VectorLayer\(\{ source: source\(\), visible: false/); assert.match(app, /reference: new VectorLayer\(\{ source: source\(\), visible: false/);
assert.match(app, /gaodeReference: new VectorLayer/);
assert.match(app, /state\.junctionReference\?\.converted/);
assert.match(app, /gaodeReferenceColors/);
assert.match(app, /scene mode must render fills only/); assert.match(app, /scene mode must render fills only/);
assert.match(app, /readFeatures\(state\.layers\.nativeRoadSurface\)/);
assert.doesNotMatch(app, /nativeRoadSurface, \(feature\) => !feature\.properties\?\.cluster_id/);
assert.doesNotMatch(app, /&& !feature\.properties\?\.cluster_preview\)/);
assert.match(app, /scenePreviewToggle\.onchange/); assert.match(app, /scenePreviewToggle\.onchange/);
assert.match(app, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/); assert.match(app, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/);
assert.match(app, /function selectJunction\(feature\)/); assert.match(app, /function selectJunction\(feature\)/);
@@ -75,6 +82,8 @@ assert.match(app, /headFeatures\.push\(new Feature/);
assert.match(app, /faceFeatures\.push\(new Feature/); assert.match(app, /faceFeatures\.push\(new Feature/);
const server = fs.readFileSync(path.join(__dirname, "road-workbench.js"), "utf8"); const server = fs.readFileSync(path.join(__dirname, "road-workbench.js"), "utf8");
assert.match(server, /\/api\/traffic-signals\/generate/); assert.match(server, /\/api\/traffic-signals\/generate/);
assert.match(server, /function compileFresh\(configPath\)/);
assert.match(server, /execFileSync\(process\.execPath, \[path\.join\(repoRoot, "scripts", "compile-native-roads\.js"\)/, "workbench regeneration must load the current compiler in a fresh process");
assert.doesNotMatch(app, /导入 QGIS|导出 QGIS/); assert.doesNotMatch(app, /导入 QGIS|导出 QGIS/);
assert.doesNotMatch(server, /traffic-signals\/(?:import|export)-qgis/); assert.doesNotMatch(server, /traffic-signals\/(?:import|export)-qgis/);
console.log("road workbench tests passed"); console.log("road workbench tests passed");

View File

@@ -3,12 +3,16 @@
"use strict"; "use strict";
const assert = require("assert"); const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { const {
buildTrafficSignalFeatures, buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures, buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures, validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences, validateTrafficSignalSourceReferences,
} = require("./lib/traffic-signals"); } = require("./lib/traffic-signals");
const { SCHEMA, loadOrGenerate } = require("./lib/native-traffic-signals");
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) { function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[ return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
@@ -41,6 +45,28 @@ assert.deepEqual(
"technical ids are deterministic", "technical ids are deterministic",
); );
const clusteredStops = { type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005, 0.000006, 0.00003), rectangle(120.00010, 30.00025),
rectangle(120.00035, 30.00005, 0.000006, 0.00003), rectangle(120.00010, 29.99985),
].map((feature) => ({ ...feature, properties: { cluster_id: "generic-complex" } })) };
const polarityAgnosticControl = { ...control, arms: [{ ...arms[0], headingDegrees: 90 }, ...arms.slice(1)] };
const ordinaryWithReversedArm = buildTrafficSignalFeatures(stops, intersections, [polarityAgnosticControl]);
assert.ok(
!new Set(stops.features.map(stopCenterKey)).has(signalStopKey(ordinaryWithReversedArm.features.find((feature) => feature.properties.source_way_id === "east"))),
"ordinary candidates retain directed matching",
);
const clustered = buildTrafficSignalFeatures(clusteredStops, { type: "FeatureCollection", features: [] }, [polarityAgnosticControl]);
assert.equal(clustered.features.length, 4, "complex stop lines form a signal group without an ordinary intersection surface");
assert.deepEqual(
new Set(clustered.features.map(signalStopKey)),
new Set(clusteredStops.features.map(stopCenterKey)),
"complex OSM arms consume each stop-line candidate exactly once",
);
assert.ok(clustered.features.every((feature) => {
const stop = [feature.properties.stop_lon, feature.properties.stop_lat];
return metersBetweenForTest(feature.geometry.coordinates, stop) > 4.8 && metersBetweenForTest(feature.geometry.coordinates, stop) < 5.6;
}), "complex signal poles are positioned from their matched stop lines");
const edited = structuredClone(cross); const edited = structuredClone(cross);
const first = edited.features[0]; const first = edited.features[0];
const originalStop = [first.properties.stop_lon, first.properties.stop_lat]; const originalStop = [first.properties.stop_lon, first.properties.stop_lat];
@@ -84,6 +110,19 @@ assert.equal(migrated.face_heading_deg, 222, "legacy heading preserves the histo
edited.features[1].properties.enabled = "0"; edited.features[1].properties.enabled = "0";
assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted"); assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted");
function metersBetweenForTest(first, second) {
return Math.hypot((first[0] - second[0]) * 111320 * Math.cos(first[1] * Math.PI / 180), (first[1] - second[1]) * 111320);
}
function stopCenterKey(feature) {
const ring = feature.geometry.coordinates[0].slice(0, -1);
return `${ring.reduce((sum, point) => sum + point[0], 0) / ring.length},${ring.reduce((sum, point) => sum + point[1], 0) / ring.length}`;
}
function signalStopKey(feature) {
return `${feature.properties.stop_lon},${feature.properties.stop_lat}`;
}
const duplicateUid = structuredClone(cross); const duplicateUid = structuredClone(cross);
duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.signal_uid; duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.signal_uid;
assert.throws(() => validateTrafficSignalFeatures(duplicateUid), /Duplicate signal_uid/); assert.throws(() => validateTrafficSignalFeatures(duplicateUid), /Duplicate signal_uid/);
@@ -129,4 +168,31 @@ const missingDirections = structuredClone(cross);
for (const key of ["heading_deg", "mast_heading_deg", "face_heading_deg"]) missingDirections.features[0].properties[key] = null; for (const key of ["heading_deg", "mast_heading_deg", "face_heading_deg"]) missingDirections.features[0].properties[key] = null;
assert.throws(() => validateTrafficSignalFeatures(missingDirections), /missing mast_heading_deg/); assert.throws(() => validateTrafficSignalFeatures(missingDirections), /missing mast_heading_deg/);
const changedOsm = `
<osm>
<node id="control-1" lon="120.0001" lat="30.00005"><tag k="highway" v="traffic_signals" /></node>
<node id="w1" lon="119.9998" lat="30.00005" />
<node id="n1" lon="120.0001" lat="30.00035" />
<node id="e1" lon="120.0004" lat="30.00005" />
<way id="west"><nd ref="w1" /><nd ref="control-1" /><tag k="highway" v="primary" /></way>
<way id="north"><nd ref="control-1" /><nd ref="n1" /><tag k="highway" v="primary" /></way>
<way id="east"><nd ref="control-1" /><nd ref="e1" /><tag k="highway" v="primary" /></way>
</osm>`;
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "native-traffic-signals-"));
const generatedSignalPath = path.join(temporaryDirectory, "generated.json");
try {
fs.writeFileSync(generatedSignalPath, JSON.stringify({ schema: SCHEMA, provenance: "generated:osm-controls", assemblies: cross }));
const regenerated = loadOrGenerate(generatedSignalPath, changedOsm, stops, intersections);
assert.equal(regenerated.assemblies.features.length, 3, "stale OSM-generated signals must refresh after OSM approaches change");
fs.writeFileSync(generatedSignalPath, JSON.stringify({ schema: SCHEMA, provenance: "edited:workbench", assemblies: cross }));
assert.throws(
() => loadOrGenerate(generatedSignalPath, changedOsm, stops, intersections),
/approach_id .* is not present on OSM control/,
"manually maintained signals must not be silently replaced",
);
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
console.log("Traffic signal tests passed."); console.log("Traffic signal tests passed.");

View File

@@ -61,6 +61,7 @@ controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked
const signalsToggle = document.createElement("label"); const signalsToggle = document.createElement("label");
signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施'; signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle); document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle);
const gaodeReferenceColors = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
let state; let state;
let selectedRoad = null; let selectedRoad = null;
@@ -83,6 +84,7 @@ const signalPicker = signalPanel.querySelector('[name="signal-picker"]');
const source = () => new VectorSource(); const source = () => new VectorSource();
const layers = { const layers = {
reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }), reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
gaodeReference: new VectorLayer({ source: source(), visible: true, zIndex: 1, style: (feature) => { const color = gaodeReferenceColors[feature.get("type")] || "#475569"; return new Style({ fill: new Fill({ color: `${color}26` }), stroke: new Stroke({ color, width: 1.5 }) }); } }),
native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }), native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }),
edgeLines: new VectorLayer({ source: source(), visible: false, style: markingStyle }), edgeLines: new VectorLayer({ source: source(), visible: false, style: markingStyle }),
sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }), sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }),
@@ -99,7 +101,7 @@ const layers = {
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }), selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }), selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
}; };
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) }); const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null }); const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
map.addInteraction(select); map.addInteraction(select);
select.on("select", ({ selected }) => { select.on("select", ({ selected }) => {
@@ -155,7 +157,7 @@ function osmDirectionLabel(road) { return road?.direction === "forward" ? "沿 O
function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; } function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); } function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; } function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); } function laneStyle(feature) { const roadId = feature.get("road_id"); const selected = Boolean(roadId && selectedRoad && roadId === selectedRoad.id); const composite = feature.get("cluster_preview"); return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : composite ? 1.6 : 1.3, lineDash: composite ? [7, 5] : [5, 4] }) }); }
function markingStyle(feature) { const yellow = feature?.get("color") === "yellow"; return new Style({ fill: new Fill({ color: yellow ? "#f5be2a" : "#f5f6ee" }), stroke: new Stroke({ color: yellow ? "#d29d16" : "#d9dacf", width: 1 }) }); } function markingStyle(feature) { const yellow = feature?.get("color") === "yellow"; return new Style({ fill: new Fill({ color: yellow ? "#f5be2a" : "#f5f6ee" }), stroke: new Stroke({ color: yellow ? "#d29d16" : "#d9dacf", width: 1 }) }); }
function signalAssemblyStyle(feature) { const component = feature.get("signal_component"); if (component === "mast") return [new Style({ stroke: new Stroke({ color: "#fff", width: 9 }) }), new Style({ stroke: new Stroke({ color: "#007f99", width: 5 }) })]; if (component === "face") return [new Style({ stroke: new Stroke({ color: "#fff", width: 7 }) }), new Style({ stroke: new Stroke({ color: "#df2435", width: 3 }) })]; if (component === "head") { const heading = Number(feature.get("face_heading_deg")) || 0; return new Style({ image: new RegularShape({ points: 3, radius: 8, rotation: heading * Math.PI / 180, fill: new Fill({ color: "#df2435" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } return new Style({ image: new RegularShape({ points: 4, radius: 6, angle: Math.PI / 4, fill: new Fill({ color: "#263630" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } function signalAssemblyStyle(feature) { const component = feature.get("signal_component"); if (component === "mast") return [new Style({ stroke: new Stroke({ color: "#fff", width: 9 }) }), new Style({ stroke: new Stroke({ color: "#007f99", width: 5 }) })]; if (component === "face") return [new Style({ stroke: new Stroke({ color: "#fff", width: 7 }) }), new Style({ stroke: new Stroke({ color: "#df2435", width: 3 }) })]; if (component === "head") { const heading = Number(feature.get("face_heading_deg")) || 0; return new Style({ image: new RegularShape({ points: 3, radius: 8, rotation: heading * Math.PI / 180, fill: new Fill({ color: "#df2435" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } return new Style({ image: new RegularShape({ points: 4, radius: 6, angle: Math.PI / 4, fill: new Fill({ color: "#263630" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); }
function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); } function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); }
@@ -163,6 +165,9 @@ function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines // Split road features meet at OSM junction nodes. Their per-feature outlines
// are editing aids, not physical seams, so scene mode must render fills only. // are editing aids, not physical seams, so scene mode must render fills only.
if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) }); if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) });
if (feature.get("cluster_id") && feature.get("complex_part")) return new Style({ fill: new Fill({ color: "#6f948a" }) });
if (feature.get("kind") === "cluster") return new Style({ fill: new Fill({ color: "rgba(20, 132, 112, .5)" }), stroke: new Stroke({ color: "#075e4f", width: 4, lineDash: [10, 5] }) });
if (feature.get("template")) return new Style({ fill: new Fill({ color: "rgba(20, 132, 112, .46)" }), stroke: new Stroke({ color: "#087c67", width: 3, lineDash: [7, 4] }) });
return feature.get("native_id")?.startsWith("junction:") return feature.get("native_id")?.startsWith("junction:")
? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) }) ? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) })
: new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) }); : new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) });
@@ -180,24 +185,25 @@ function roadForFeature(feature) {
const roadId = properties.road_id || properties.subjectId || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0]; const roadId = properties.road_id || properties.subjectId || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
return state.compiled.model.roads.find((road) => road.id === roadId) || null; return state.compiled.model.roads.find((road) => road.id === roadId) || null;
} }
function junctionForFeature(feature) { const id = feature.get("native_id"); return id?.startsWith("junction:") ? layers.native.getSource().getFeatures().find((item) => item.get("native_id") === id) : null; } function junctionForFeature(feature) { const id = feature.get("native_id"); return id?.startsWith("junction:") || id?.startsWith("junction-cluster:") ? layers.native.getSource().getFeatures().find((item) => item.get("native_id") === id) : null; }
function endpointFor(road, side) { return state.compiled.model.endpoints.find((endpoint) => endpoint.roadId === road?.id && endpoint.side === side) || null; } function endpointFor(road, side) { return state.compiled.model.endpoints.find((endpoint) => endpoint.roadId === road?.id && endpoint.side === side) || null; }
function endpointsCompatible(from, to) { if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false; if (from.nodeId === to.nodeId) return true; const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180); const dy = (from.coordinate[1] - to.coordinate[1]) * 111320; return Math.hypot(dx, dy) <= 35; } function endpointsCompatible(from, to) { if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false; if (from.nodeId === to.nodeId) return true; const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180); const dy = (from.coordinate[1] - to.coordinate[1]) * 111320; return Math.hypot(dx, dy) <= 35; }
function readFeatures(collection) { return geojson.readFeatures(collection || { type: "FeatureCollection", features: [] }, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); } function readFeatures(collection, predicate = null) { const source = collection || { type: "FeatureCollection", features: [] }; const filtered = predicate ? { ...source, features: (source.features || []).filter(predicate) } : source; return geojson.readFeatures(filtered, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); }
function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857"), road_id: road.id })); } function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857"), road_id: road.id })); }
function updateSources() { function updateSources() {
layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface)); layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface));
layers.gaodeReference.getSource().clear(); layers.gaodeReference.getSource().addFeatures(readFeatures(state.junctionReference?.converted));
layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]); layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]);
layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface)); layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface));
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures()); layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines)); layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines, (feature) => !feature.properties?.cluster_internal && !feature.properties?.cluster_preview_hidden));
layers.edgeLines.getSource().clear(); layers.edgeLines.getSource().addFeatures(readFeatures(state.layers.edgeLines)); layers.edgeLines.getSource().clear(); layers.edgeLines.getSource().addFeatures(readFeatures(state.layers.edgeLines));
layers.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows)); layers.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows, (feature) => !feature.properties?.cluster_preview_hidden));
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]); layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators, (feature) => !feature.properties?.cluster_preview_hidden), ...readFeatures(state.layers.turnArrows, (feature) => !feature.properties?.cluster_preview_hidden)]);
layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines)); layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines, (feature) => !feature.properties?.cluster_preview_hidden));
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]); layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue; const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue;
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors)); layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors, (feature) => !feature.properties?.cluster_internal));
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) })); layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 }); const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
} }
@@ -220,8 +226,8 @@ function selectJunction(feature) {
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false; selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean); const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean); const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);
junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2); junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id || properties.osm_node_ids, 类型: properties.kind === "cluster" ? "复合路口簇" : properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 模板: properties.template, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2);
message(`已选中路口OSM 节点 ${properties.osm_node_id}`); message(`已选中路口OSM 节点 ${properties.osm_node_id || properties.osm_node_ids}`);
} }
function selectSignal(feature) { function selectSignal(feature) {
selectedSignal = feature.get("signal_uid"); const p = feature.getProperties(); signalPicker.value = selectedSignal; selectedSignal = feature.get("signal_uid"); const p = feature.getProperties(); signalPicker.value = selectedSignal;
@@ -304,13 +310,30 @@ centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCent
centerLineStyleInput.onchange = stageSelectedCenterLineStyle; centerLineStyleInput.onchange = stageSelectedCenterLineStyle;
async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; } async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }
saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); }; saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); };
compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已保存并重新生成"); }; compileButton.onclick = async () => {
if (!await saveStagedChanges()) return;
message("正在保存修改并重新生成...");
const response = await fetch("/api/compile", { method: "POST", cache: "no-store" });
const nextState = await response.json();
if (!response.ok || nextState.ok === false || !nextState.compiled?.model || !nextState.layers) {
message(`重新生成失败:${nextState.error || `HTTP ${response.status}`}`);
return;
}
state = nextState;
staged = [];
updateDirtyState();
updateSources();
renderDiagnostics();
renderSummary();
selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null);
message("已保存并重新生成");
};
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { const visible = input.checked; layers[input.dataset.layer].setVisible(visible); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(visible); }; for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { const visible = input.checked; layers[input.dataset.layer].setVisible(visible); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(visible); };
scenePreviewToggle.onchange = () => { scenePreviewToggle.onchange = () => {
scenePreview = scenePreviewToggle.checked; scenePreview = scenePreviewToggle.checked;
for (const input of document.querySelectorAll("[data-layer]")) { for (const input of document.querySelectorAll("[data-layer]")) {
const layer = input.dataset.layer; const layer = input.dataset.layer;
if (["osm", "lanes", "reference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked); if (["osm", "lanes", "reference", "gaodeReference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked);
} }
layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked); layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked);
layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked); layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked);

View File

@@ -1,4 +1,4 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header> <body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2 id="marking-style-heading">道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存标线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html> <main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="gaodeReference" type="checkbox" checked> 高德规整路口参考</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2 id="marking-style-heading">道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存标线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>