diff --git a/.gitignore b/.gitignore index 9a0e112..cec3e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ outputs/ __pycache__/ assets/models/speedtree/ assets/models/lyrog/ +inputs/osm \ No newline at end of file diff --git a/.trellis/spec/config/index.md b/.trellis/spec/config/index.md index 43624ba..8c912d4 100644 --- a/.trellis/spec/config/index.md +++ b/.trellis/spec/config/index.md @@ -54,6 +54,7 @@ cp config/examples/template.json config/areas/my-area.json | `turnLaneArrows` | | 见下 | 从 OSM `turn:lanes:*` 生成自定义车道箭头的发布开关 | | `osm2streets` | | 见下 | 透传给 osm2streets 的选项 | | `blender` | | 见下 | Blender 侧选项 | +| `nativeRoad` | | 见下 | 原生道路编译选项 | | `compress` | | 见下 | 默认交付压缩阶段的 GLB 压缩选项 | | `budget` | | 见下 | 区域 GLB 性能与体量预算 | | `outputs` | | 从 `id` 推导 | 输出路径覆盖,逃生舱 | @@ -134,6 +135,17 @@ cp config/examples/template.json config/areas/my-area.json | `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 | | `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`(4–25) | 仅 `complex-junction-v1`:相邻进口夹角处路缘圆角的半径。圆角切于两侧最外道路边缘,只补齐夹角处的路面,不改变 connector、信号或停止线。 | +| `junctionTemplates.clusters[].outerRadiusExtraMeters` | `18`(18–35) | 仅 `complex-junction-v1`:路口中心到外部进口交接边界的额外半径。用于让圆角包住角部斑马线;未配置时保持原有 18 m。 | + ### `compress` 完整构建和显式 `--stages compress` 都使用此配置。默认压缩链是 texture resize + WebP diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 3a5ee06..3908911 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -815,6 +815,32 @@ area.outputs.trafficSignals - `layout.mastHeightMeters` 与 `layout.headCenterHeightMeters` 必须相等,表示横杆与灯壳的 中心对齐;`lensVerticalOffsetsMeters` 以灯壳中心为基准,正值向上、负值向下。当前倒计时牌 垂直偏移为 `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 @@ -824,6 +850,9 @@ area.outputs.trafficSignals | 直接运行 `blender` / `preview` 但锚点不存在 | 在启动外部工具前报 `Traffic signal anchors not found` | | `signal_uid` 缺失/重复、非空 `display_id` 重复、字段或 Point 无效 | 重导入在替换任何输出前失败 | | 用户仅修改 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 diff --git a/.trellis/spec/pipeline/index.md b/.trellis/spec/pipeline/index.md index fdff2dc..c902cf3 100644 --- a/.trellis/spec/pipeline/index.md +++ b/.trellis/spec/pipeline/index.md @@ -15,6 +15,7 @@ | 加阶段、加 CLI 参数、改配置字段 | [CLI 与阶段](./cli-and-stages.md) | | 改区域诊断/质量门命令或共享区域配置归一化 | [CLI 与阶段](./cli-and-stages.md#区域诊断命令) 和 [质量门](./cli-and-stages.md#区域质量门命令) | | 改 stage manifest 写入、读取或 stale 判断 | [CLI 与阶段](./cli-and-stages.md#stage-manifest-契约) | +| 改复合路口几何(`buildComplexJunctionGeometry`、车道控制避让、人行道转角) | [CLI 与阶段](./cli-and-stages.md#可编辑信号设施与运行时锚点的跨阶段消费) 的复合路口小节 | | 改预览页生成 | [../preview/](../preview/index.md) | | 声称"纯重构,产物不变" | [产物一致性指南](../guides/artifact-parity-guide.md) | diff --git a/.trellis/tasks/08-19-gaode-junction-reference/check.jsonl b/.trellis/tasks/08-19-gaode-junction-reference/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-19-gaode-junction-reference/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-19-gaode-junction-reference/design.md b/.trellis/tasks/08-19-gaode-junction-reference/design.md new file mode 100644 index 0000000..02561f5 --- /dev/null +++ b/.trellis/tasks/08-19-gaode-junction-reference/design.md @@ -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. diff --git a/.trellis/tasks/08-19-gaode-junction-reference/implement.jsonl b/.trellis/tasks/08-19-gaode-junction-reference/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-19-gaode-junction-reference/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-19-gaode-junction-reference/implement.md b/.trellis/tasks/08-19-gaode-junction-reference/implement.md new file mode 100644 index 0000000..bfb0b75 --- /dev/null +++ b/.trellis/tasks/08-19-gaode-junction-reference/implement.md @@ -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 → 0,connector 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. diff --git a/.trellis/tasks/08-19-gaode-junction-reference/prd.md b/.trellis/tasks/08-19-gaode-junction-reference/prd.md new file mode 100644 index 0000000..3fb3673 --- /dev/null +++ b/.trellis/tasks/08-19-gaode-junction-reference/prd.md @@ -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 Drift(2026-08-21 记录) + +R6.2 原定「首个生产几何验证只启用 cross 模板;T/复杂路口先保留现有 native 行为」。 +实际实现走的是 `complex-junction-v1`,对枫树二路 4 节点簇做整体路口面、斑马线、停止线、 +角部安全岛和圆角生成。范围已越过 R6.2,本文档据实修订,R6.2 视为被本次决策取代。 +cross 模板(`cross-v1` / `cross-cluster-v1`)代码路径仍在,未被删除。 + +## Verification Log(2026-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 IoU(0.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 Gaps(2026-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` 值的业务语义;先验证几何范式和校准边界。 +- 暂不提交代码、配置或生成产物;本阶段完成后由用户决定是否进入正式实现。 diff --git a/.trellis/tasks/08-19-gaode-junction-reference/task.json b/.trellis/tasks/08-19-gaode-junction-reference/task.json new file mode 100644 index 0000000..7039bd3 --- /dev/null +++ b/.trellis/tasks/08-19-gaode-junction-reference/task.json @@ -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": {} +} \ No newline at end of file diff --git a/blender/README.md b/blender/README.md index c202af0..1b7cab0 100644 --- a/blender/README.md +++ b/blender/README.md @@ -55,6 +55,20 @@ npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json ## 导出为 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//python/bin/python3.*`。 +安装后必须使用同一 Blender 安装启动导出脚本,系统 Python 中安装的 `tqdm` 不会自动被 +Blender 发现。 + ```bash /Applications/Blender.app/Contents/MacOS/Blender \ --background \ diff --git a/blender/export_cesium.py b/blender/export_cesium.py index d627d62..08cfe3a 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -16,6 +16,11 @@ import sys import bpy 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 # osmassets package next to this file is not importable without this. _HERE = os.path.dirname(os.path.abspath(__file__)) @@ -603,9 +608,39 @@ def semantic_asset_specs(glb_path, meshes): 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): if not os.path.exists(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"]) material_map = {} @@ -613,13 +648,13 @@ def export(args): dynamic_meshes = [] countdown_meshes = {0: [], 1: []} unwrapped = set() - for obj in bpy.context.scene.objects: - if obj.type != "MESH": - continue - if obj.name == "Ground Plane": - continue - if obj.hide_viewport or obj.hide_render: - continue + scene_objects = list(bpy.context.scene.objects) + candidates = [obj for obj in scene_objects + if obj.type == "MESH" and obj.name != "Ground Plane" + and not obj.hide_viewport and not obj.hide_render] + total_candidates = max(1, len(candidates)) + progress.update("prepare scene", 3, "%d mesh objects" % len(candidates)) + for index, obj in enumerate(candidates, 1): 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} 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: material_map[source.name] = make_export_material(source) 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 not dynamic_meshes: 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")): if not args.get(key): continue if not countdown_meshes[group]: raise RuntimeError("Traffic countdown collection %d is empty" % group) - export_glb(args[key], countdown_meshes[group]) - semantic_assets = semantic_asset_specs(args["glb"], meshes) + exports.append((args[key], countdown_meshes[group], "countdown group %d" % group)) 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 try: @@ -711,6 +761,8 @@ def export(args): json.dump(metadata, handle, ensure_ascii=False, indent=2) handle.write("\n") + progress.update("complete", 100, "all assets exported") + progress.close() print("CESIUM_EXPORT_DONE", json.dumps({ "glb": args["glb"], "metadata": args["metadata"], "meshes": len(meshes), "materials": len(material_map), diff --git a/blender/requirements.txt b/blender/requirements.txt new file mode 100644 index 0000000..d6f218f --- /dev/null +++ b/blender/requirements.txt @@ -0,0 +1,2 @@ +# Optional runtime helper for export_cesium.py progress display. +tqdm>=4.66,<5 diff --git a/config/areas/fengshu-er-road.json b/config/areas/fengshu-er-road.json index cabc279..266a514 100644 --- a/config/areas/fengshu-er-road.json +++ b/config/areas/fengshu-er-road.json @@ -25,7 +25,24 @@ "enabled": false }, "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": { "debug_each_step": false, diff --git a/config/examples/template.json b/config/examples/template.json index 45cff31..3b3f221 100644 --- a/config/examples/template.json +++ b/config/examples/template.json @@ -25,7 +25,12 @@ "enabled": false }, "nativeRoad": { - "edgeLines": false + "edgeLines": false, + "junctionTemplates": { + "enabled": false, + "references": [], + "clusters": [] + } }, "osm2streets": { "debug_each_step": false, diff --git a/package.json b/package.json index 950463b..4dc0b12 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,12 @@ "diagnose:area": "node scripts/diagnose-area.js", "preflight:area": "node scripts/preflight-area.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:workbench": "node scripts/road-workbench.js", "test:road-workbench": "node scripts/test-road-workbench.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:build-stages": "node scripts/test-build-stages.js", "test:budgets": "node scripts/test-asset-budgets.js", diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js index b1d2eb6..b7b478c 100644 --- a/scripts/compile-native-roads.js +++ b/scripts/compile-native-roads.js @@ -25,7 +25,7 @@ function compileArea(configPath) { const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); validateOverrides(overrides, model); 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 signalRuntime = runtime(signalDocument); // Persist validation normalization, including one-time legacy heading migration. diff --git a/scripts/inspect-junction-reference.js b/scripts/inspect-junction-reference.js new file mode 100644 index 0000000..0dd8f55 --- /dev/null +++ b/scripts/inspect-junction-reference.js @@ -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(); diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 5f47c10..3ba229b 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -113,6 +113,7 @@ function normalizeAreaConfig(raw, options = {}) { }, nativeRoad: { edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"), + junctionTemplates: normalizeJunctionTemplates(raw.nativeRoad?.junctionTemplates, repoRoot), }, osm2streets: raw.osm2streets || { 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) { if (raw !== undefined && raw !== null && (typeof raw !== "object" || Array.isArray(raw))) { throw new Error("budget must be an object"); diff --git a/scripts/lib/complex-junction.js b/scripts/lib/complex-junction.js new file mode 100644 index 0000000..e3a072b --- /dev/null +++ b/scripts/lib/complex-junction.js @@ -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 }; diff --git a/scripts/lib/gaode-junction-reference.js b/scripts/lib/gaode-junction-reference.js new file mode 100644 index 0000000..7d8eac1 --- /dev/null +++ b/scripts/lib/gaode-junction-reference.js @@ -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(/]*?)(?:\/>|>([\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(/]*)\/?\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 ``; + }).join("\n"); + const nativePaths = (nativeIntersection?.features || []).map((feature) => ``).join("\n"); + return ` + + + ${references} + ${nativePaths} + + Gaode reference (type colors) / native intersection (red) +`; +} + +module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg }; diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js index 25d308a..f0073b6 100644 --- a/scripts/lib/native-road.js +++ b/scripts/lib/native-road.js @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows"); +const { buildComplexJunctionGeometry, complexJunctionMetrics } = require("./complex-junction"); const OVERRIDE_SCHEMA = "native-road-overrides/v1"; 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_PATTERNS = new Set(["dashed", "solid"]); 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; function parseOsmRoads(xml) { @@ -244,33 +248,171 @@ function nearbyManualCandidates(endpoints, from) { function compileGeometry(model, overrides = { overrides: [] }, options = {}) { const diagnostics = [...model.diagnostics]; - const junctionPlans = compileJunctionPlans(model); + const junctionPlans = compileJunctionPlans(model, options, diagnostics); 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 generatedComplexSidewalks = []; + const generatedComplexCrosswalks = []; + const generatedComplexStopLines = []; for (const road of model.roads) { const segmentKey = road.segmentId; if (emittedSegments.has(segmentKey)) continue; emittedSegments.add(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); // The approach surface stops at the junction cutback. The junction layer // owns the intervening rounded corners; leaving approaches untrimmed // 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; } 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 controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans); - const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); - const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls); - const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); + const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines] }; + const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options); + 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 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); - 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) { @@ -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] } }; } -function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) { +function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics, options = {}) { const features = []; const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; const segments = new Map(); @@ -317,18 +459,24 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti const forward = roads.find((road) => road.direction === "forward"); const backward = roads.find((road) => road.direction === "backward"); 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); 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 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 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) { - const placement = pointAndAxisAlongLine(line, start + markLength / 2); + for (let start = 0, dashIndex = 1; start + markLength <= visibleLength; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) { + const placement = pointAndAxisAlongLine(visibleLine, start + markLength / 2); 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); - 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, 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, 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] } }); } } } 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 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 controlFeatures = [...controls.crosswalks, ...controls.stopLines]; 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) { const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates; 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 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" }; - 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] } }); } - 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 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 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 maneuvers = turns ? String(turns).split("|") : []; 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 rings = arrowRingsAt(maneuver, center, axis); 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 }; } +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 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))); } -function compileSidewalkSurfaces(model, diagnostics, junctionPlans) { +function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}) { const features = []; const byWay = new Map(); for (const road of model.roads) { @@ -461,22 +642,27 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans) { ["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)], ["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) { 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); 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}`; - 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; } -function compileSidewalkCorners(model, junctionPlans) { +function compileSidewalkCorners(model, junctionPlans, options = {}) { const result = []; for (const [nodeId, plan] of junctionPlans) { + if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue; const candidates = []; for (const approach of plan.approaches) { const directions = model.roads.filter((road) => road.segmentId === approach.segmentId); @@ -589,10 +775,15 @@ function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) { } 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) { const junction = junctionByNode.get(connector.properties.node_id); if (!junction) continue; + if (junction.properties.kind === "cluster") continue; const ring = junction.geometry.coordinates[0]; 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])); @@ -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; } -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 controlFeatures = [...(complexControls.crosswalks || []), ...(complexControls.stopLines || [])]; 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) { const lanes = []; + const markingLanes = []; const laneWidth = road.widthMeters / road.laneCount; const siblings = model.roads.filter((item) => item.segmentId === road.segmentId); 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, // offset each directed carriageway to its own side before placing lanes. 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. const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5)); 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 }; 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); + 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) { @@ -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 }; 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; } - 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); } } @@ -793,9 +1070,11 @@ function polygonAreaMeters(ring) { return Math.abs(twiceArea) / 2; } -function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) { +function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics, options = {}) { 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) { + if (plan.clusterId && complexClusters.has(plan.clusterId)) continue; const { segmentIds, node, approaches, cutbackMeters, boundary } = plan; const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === 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 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 (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; } -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(); for (const endpoint of model.endpoints) { if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); byNode.get(endpoint.nodeId).push(endpoint); } 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) { const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, ""))); if (segmentIds.size < 3 || segmentIds.size > 4) continue; @@ -839,11 +1153,14 @@ function compileJunctionPlans(model) { if (approaches.length !== segmentIds.size) continue; // Rounded curb corners need enough approach length to retain the full // 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 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; - 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; } @@ -863,13 +1180,13 @@ function junctionApproaches(model, endpoints) { }); } -function junctionBoundary(approaches, node, cutbackMeters) { +function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) { const points = []; for (const approach of approaches) { const cutback = pointAlongLine(approach.line, cutbackMeters); if (!cutback) continue; 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 }); } @@ -886,7 +1203,7 @@ function junctionBoundary(approaches, node, cutbackMeters) { // bends the far side of a T junction and exposes junction asphalt beyond // the pedestrian strip. 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; } boundary.push(...curve.slice(1, -1)); rounded += 1; @@ -894,13 +1211,50 @@ function junctionBoundary(approaches, node, cutbackMeters) { 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) { if (first.sourceWayKey !== second.sourceWayKey) return false; const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180; 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 a = project(first, origin); const b = project(second, origin); 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 // than publishing a huge/self-crossing curve. 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); } +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) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; @@ -978,6 +1373,42 @@ function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) { 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 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]; } diff --git a/scripts/lib/native-traffic-signals.js b/scripts/lib/native-traffic-signals.js index 8c9f774..58069f5 100644 --- a/scripts/lib/native-traffic-signals.js +++ b/scripts/lib/native-traffic-signals.js @@ -12,10 +12,26 @@ const { const SCHEMA = "native-traffic-signals/v1"; 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); } +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) { const controls = parseOsm(osmText).trafficSignalControls; return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) }; diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index 7c31ac2..e3f8d11 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -23,20 +23,38 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) { const point = polygonCenter(feature.geometry); return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, 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 = []; for (const feature of stopLines.features || []) { const center = polygonCenter(feature.geometry); 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; const axis = roadAxis(feature.geometry, center, intersection.point); if (!axis) continue; const right = [axis[1], -axis[0]]; - const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2); candidates.push({ 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), + matchHeadingDegrees: intersection.clusterId + ? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI) + : null, }); } const features = []; @@ -180,25 +198,34 @@ function signalNodeKey(signalUid) { 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 approachesByControl = new Map((controls || []).map((control) => [ String(control.id), new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)), ])); + const kept = []; + const dropped = []; 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); 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)) { - throw new Error( - `traffic signal feature ${index + 1}: approach_id '${approachId}' is not present on OSM control '${controlId}'`, - ); + dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `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 = []) { @@ -237,11 +264,20 @@ function uniqueApproachArms(candidates, controlPoint) { } 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); return osmArms.map((osmArm) => { let bestIndex = -1; let bestDistance = Infinity; - remaining.forEach((item, index) => { const distance = angularDistance(item.armHeading, osmArm.headingDegrees); if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } }); + 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); return { ...candidate, osmArm }; }); diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js index 17c868b..ac45ae0 100644 --- a/scripts/road-workbench.js +++ b/scripts/road-workbench.js @@ -4,10 +4,12 @@ const fs = require("fs"); const http = require("http"); const path = require("path"); +const { execFileSync } = require("child_process"); const { readAreaConfig } = require("./lib/area-config"); const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road"); const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals"); const { compileArea, parseArgs } = require("./compile-native-roads"); +const { convertGeoJson } = require("./lib/gaode-junction-reference"); 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")); if (args.noCompile !== "true") compileArea(configPath); const area = readAreaConfig(configPath, { repoRoot }); + const junctionReference = args.junctionReference ? readJunctionReference(path.resolve(args.junctionReference)) : null; 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]."); - 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) => { console.error(`Road Workbench failed to listen: ${error.message}`); 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}/`)); } -function handle(request, response, area, configPath) { +function handle(request, response, area, configPath, junctionReference) { 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 === "/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.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) => { const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); writeJsonAtomic(area.outputs.nativeTrafficSignals, document); @@ -54,20 +57,37 @@ function handle(request, response, area, configPath) { sendJson(response, 200, { ok: true, overrides }); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => { - compileArea(configPath); - sendJson(response, 200, state(area)); + compileFresh(configPath); + sendJson(response, 200, state(area, junctionReference)); }).catch((error) => sendJson(response, 500, { ok: false, error: error.message })); sendJson(response, 404, { error: "Not found" }); } -function state(area) { +function state(area, junctionReference = null) { const nativeDir = area.outputs.nativeRoadDir; const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson"); const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals) ? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")) : { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }; 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 readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } diff --git a/scripts/test-gaode-junction-reference.js b/scripts/test-gaode-junction-reference.js new file mode 100644 index 0000000..a601884 --- /dev/null +++ b/scripts/test-gaode-junction-reference.js @@ -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, ""); + 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."); diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 0e81869..6846930 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -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.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.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) => 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")); @@ -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)); 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)); +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"); assert.equal(crossSidewalkCorners.length, 4); // A rounded sidewalk corner must sample both the curb and outer boundaries. @@ -217,6 +391,67 @@ try { function distance(a, b) { 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) { 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); diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js index 3b88826..a54e955 100644 --- a/scripts/test-road-workbench.js +++ b/scripts/test-road-workbench.js @@ -8,6 +8,7 @@ const path = require("path"); 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, /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="selected-junction" hidden/); 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, /function nativeSurfaceStyle\(feature\)/); 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, /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, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/); assert.match(app, /function selectJunction\(feature\)/); @@ -75,6 +82,8 @@ assert.match(app, /headFeatures\.push\(new Feature/); assert.match(app, /faceFeatures\.push\(new Feature/); const server = fs.readFileSync(path.join(__dirname, "road-workbench.js"), "utf8"); 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(server, /traffic-signals\/(?:import|export)-qgis/); console.log("road workbench tests passed"); diff --git a/scripts/test-traffic-signals.js b/scripts/test-traffic-signals.js index b3e7e67..16c8c0a 100644 --- a/scripts/test-traffic-signals.js +++ b/scripts/test-traffic-signals.js @@ -3,12 +3,16 @@ "use strict"; const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); const { buildTrafficSignalFeatures, buildTrafficSignalsFromFeatures, validateTrafficSignalFeatures, validateTrafficSignalSourceReferences, } = require("./lib/traffic-signals"); +const { SCHEMA, loadOrGenerate } = require("./lib/native-traffic-signals"); function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) { return { type: "Feature", geometry: { type: "Polygon", coordinates: [[ @@ -41,6 +45,28 @@ assert.deepEqual( "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 first = edited.features[0]; 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"; 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); duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.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; assert.throws(() => validateTrafficSignalFeatures(missingDirections), /missing mast_heading_deg/); +const changedOsm = ` + + + + + + + + + `; +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."); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index 0426c19..0d052d0 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -61,6 +61,7 @@ controlsToggle.innerHTML = ' new VectorSource(); 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 }) }) }), + 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 }), edgeLines: new VectorLayer({ source: source(), visible: false, style: markingStyle }), 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 }), 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 }); map.addInteraction(select); 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 laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); } 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 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 }) }); } @@ -163,6 +165,9 @@ function nativeSurfaceStyle(feature) { // 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. 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:") ? 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 }) }); @@ -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]; 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 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 updateSources() { 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.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface)); 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.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows)); - layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]); - layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines)); + 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, (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, (feature) => !feature.properties?.cluster_preview_hidden)); 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; - 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 })) })); 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; 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); - 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); - message(`已选中路口:OSM 节点 ${properties.osm_node_id}`); + 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 || properties.osm_node_ids}`); } function selectSignal(feature) { 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; 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("已保存,点击“保存并重新生成”写入几何"); }; -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); }; scenePreviewToggle.onchange = () => { scenePreview = scenePreviewToggle.checked; for (const input of document.querySelectorAll("[data-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.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked); diff --git a/scripts/workbench/index.html b/scripts/workbench/index.html index 866a9cd..528b28e 100644 --- a/scripts/workbench/index.html +++ b/scripts/workbench/index.html @@ -1,4 +1,4 @@ 道路编译工作台
道路编译工作台
-
+