feat: add native road direction arrows

This commit is contained in:
2026-08-14 18:05:21 +08:00
parent 822e6ef936
commit 1b9829d9ed
18 changed files with 386 additions and 11 deletions

View File

@@ -367,6 +367,53 @@ if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return n
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`); fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
``` ```
## Native 道路标线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 将原生道路标线写入
`outputs/<area>/native-road/layers/`。它不读取 osm2streets 渲染几何;只复用
`turn-lane-arrows.js` 中已测试的箭头模板。工作台 `GET /api/state` 原样服务这些
GeoJSONnative Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们。
### 2. 图层契约
| 文件 | 语义 | 必需 provenance | Blender material layer |
|---|---|---|---|
| `lane_separators.geojson` | 同向相邻车道的分隔线 | `native-road-lane-separator/v1` | `lane_separators` |
| `direction_arrows.geojson` | 沿定向车道重复的直行方向箭头 | `native-road-direction-arrow/v1` | `lane_arrows_webscale` |
| `turn_arrows.geojson` | 明确 `turn:lanes` 的路口动作箭头 | `native-road-turn-arrow/v1` | `lane_arrows_webscale` |
方向箭头必须带 `road_id``lane_id``osm_way_ids``direction``lane_index`
`sequence``distance_along_lane_meters``placement_interval_meters`。转向箭头
必须带 `maneuver``placement_distance_meters`。两者不能共用 provenance 或假装为
彼此:前者表达沿路行驶方向,后者表达路口处允许动作。
### 3. 放置与错误矩阵
| 条件 | 结果 |
|---|---|
| 车道长度不足以容纳两端 14m 缓冲 | 不生成道路方向箭头 |
| 可用车道长度 | 从 14m 起按 32m 间距生成 `through` 箭头 |
| OSM 未提供 `turn:lanes` | 不生成路口转向箭头 |
| `turn:lanes` 存在但动作不受已测试模板支持 | 记录诊断,不猜测动作 |
| native Blender 构建缺任一图层文件 | 在 `ensureNativeRoadLayers()` 失败,不能静默漏画 |
### 4. 必需测试
- `npm run test:native-road`:方向箭头的 Polygon、provenance、间距以及无标签道路
不生成路口转向箭头。
- `npm run test:road-workbench`:方向箭头开关、选择溯源和概览标签。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志必须列出
`direction_arrows``turn_arrows`,且不运行 `package`
### 5. 错误与正确写法
错误:把默认 `through` 当作路口 `turn:lanes` 动作,统一写入 `turn_arrows.geojson`
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现Blender 复用同一现有箭头材质。
## 斑马线与停止线来源 ## 斑马线与停止线来源
### 1. 范围与触发条件 ### 1. 范围与触发条件

View File

@@ -18,7 +18,9 @@
"commit": null, "commit": null,
"pr_url": null, "pr_url": null,
"subtasks": [], "subtasks": [],
"children": [], "children": [
"08-14-native-road-lane-markings"
],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],
"notes": "", "notes": "",

View File

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

View File

@@ -0,0 +1,77 @@
# Native Lane Markings Design
## Architecture
The native compiler remains the source of truth. It derives two new polygon
layers alongside its existing surface, sidewalk, lane-centerline, and
connector outputs:
```text
canonical directed roads + lane centerlines + junction cutbacks
|
+-- lane separators: paint polygons between adjacent same-direction lanes
|
+-- direction arrows: repeated through-arrow template on directed lanes,
| outside the reserved junction marking zone
|
+-- turn arrows: tested existing template, anchored to an incoming lane
only when that lane has an explicit supported turn:lanes value
```
No OSM2streets rendered geometry is consumed by this path. The existing arrow
template library is reused only as a geometry/style asset, so the native lane
ID and OSM tags remain the evidence for placement.
## Contracts
- `layers/lane_separators.geojson`: polygon FeatureCollection. Each feature
records `native_id`, directed `road_id`, adjacent lane indices, source OSM
ways, and `native-road-lane-separator/v1` provenance.
- `layers/turn_arrows.geojson`: polygon FeatureCollection. Each feature records
its `native_id`, `road_id`, `lane_id`, OSM way IDs, direction, lane index,
maneuver, template asset, placement distance, and placement provenance.
- Unsupported turn values, a missing usable incoming-lane segment, or an
insufficient pre-junction placement distance create a diagnostic and no
arrow geometry.
- `layers/direction_arrows.geojson`: polygon FeatureCollection. Each feature
records the native lane and directed road, OSM way IDs, a stable sequence
index, its distance along the lane, and `native-road-direction-arrow/v1`
provenance. It uses the tested `through` template but is not a turn claim.
- The workbench serves both layers, draws them separately from its current
centerline/connector debug layer, and selects them by `native_id`.
- `catalog.NATIVE_ROAD_LAYERS` maps the two native sources to the existing
`lane_separators` and `lane_arrows_webscale` Blender materials. The native
adapter does not extend the osm2streets scene-layer registry.
## Placement
An incoming lane is oriented in driving direction. A turn arrow is sampled
from that lane's endpoint backwards by the configured safe distance, staying
outside the junction cutback. Its template basis uses the sampled lane tangent;
therefore it is on and aligned with the lane rather than the OSM centerline or
a screen-space direction. Multiple template rings remain separate polygons.
Lane separators are narrow polygons centered between adjacent lane centerlines
on a single directional carriageway. They stop at the same junction cutbacks as
the lane centerlines. A one-lane direction produces none.
Direction arrows are sampled at a fixed road-scale interval along the same
directed lane centerline. Their candidates exclude both endpoint buffers and
the turn-arrow reserve at the incoming end. This preserves a readable repeated
direction cue without overlapping a turn instruction at a junction.
## Compatibility And Rollback
All new files are additive under `native-road/layers/`. The existing
osm2streets/QGIS layer contract and `package/` are unchanged. Selecting
`--road-provider osm2streets` remains rollback. Missing native marking files
are a native Blender build error rather than a silent omission.
## Risks
- OSM turn tags can be incomplete or incompatible with the inferred lane
count. These are diagnostics, not guessed arrows.
- Very short approaches can have no safe position before the cutback. They are
skipped with a source-traceable diagnostic.
- Blender and Cesium need a real native build to verify the mesh/material
contract, not only GeoJSON unit tests.

View File

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

View File

@@ -0,0 +1,31 @@
# Implementation Plan
1. Add reusable template-placement helper to the existing turn-arrow module,
preserving its supported-asset gate and output ring shape.
2. Extend the native compiler with lane-separator, repeated road-direction
arrow, and explicit-turn-arrow polygon generation plus source diagnostics
and layer persistence.
3. Extend native output records/counts and Blender adapter mappings. Reuse
existing `lane_separators` and `lane_arrows_webscale` materials only.
4. Add Workbench layers, toggles, selection/provenance inspector entries, and
preserve the existing Workbench-only direction triangle behavior.
5. Add focused fixtures for supported turn placement, unsupported maneuver,
short approach skip, and separator geometry; run native/workbench/build
stage tests.
6. Build Nantaizi with `blender,cesium,preview --road-provider native`, inspect
the final preview, and confirm no `package/` publication occurred.
## Validation
```bash
npm run test:native-road
npm run test:road-workbench
npm run test:turn-lane-arrows
npm run test:build-stages
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json \
--stages blender,cesium,preview --road-provider native
```
Rollback is selecting `--road-provider osm2streets`; no existing output path
is replaced.

View File

@@ -0,0 +1,74 @@
# Native road lane markings and turn arrows
## Goal
Complete the native-road visual language for Nantaizi before any cross-area
migration: lane separators, travel-direction markers, and OSM-backed turn
arrows must be inspectable in the Road Workbench and visible in Blender and
Cesium output.
## Confirmed Facts
- Native output currently contains directed lane centerlines and connector
curves, but it does not emit paintable lane-separator or turn-arrow polygons.
- The existing osm2streets path has tested arrow templates in
`scripts/lib/turn-lane-arrows.js`, the `lane_arrows_webscale` material layer,
and a matching Blender material. Reuse these instead of introducing a second
arrow style.
- Earlier reviews established that direction markers must sit on the OSM / lane
centerline, use a clearly directional sharp triangle, and never be treated as
a road-surface decoration that drifts sideways.
- Turn arrows must follow the actual incoming lane and be placed before its
junction, with OSM source way, direction, lane index, and maneuver retained
as provenance.
- Scope remains Nantaizi only. Existing osm2streets output remains untouched.
## Requirements
- R1: Native compilation emits polygonal lane-separator markings derived from
its own directed lane geometry.
- R2: Native compilation emits turn-arrow polygons for supported OSM
`turn:lanes` maneuvers, using the existing tested arrow templates and the
exact native incoming-lane centerline for placement.
- R2a: Native compilation emits repeated straight-ahead direction-arrow
polygons along directed lanes, matching the visual role of osm2streets'
ordinary road arrows. These are a separate layer from turn arrows, retain
their own provenance, and leave a clear buffer around junction turn arrows.
- R3: Native output preserves provenance for every marking: native road/lane
ID, OSM way IDs, direction, lane number, maneuver, and placement method.
- R4: The Workbench renders markings in a separately controllable layer and
exposes those provenance fields on selection.
- R5: The native Blender adapter consumes native marking layers through the
existing lane-separator and lane-arrow material layers; Cesium must receive
the same geometry through the exported GLB.
- R6: Unsupported, unplaceable, or ambiguous arrow inputs become diagnostics;
the compiler must not invent a maneuver.
## Acceptance Criteria
- [ ] Nantaizi native output contains valid polygon GeoJSON for generated lane
separators, repeated road direction arrows, and every supported, explicitly
tagged turn arrow.
- [ ] A Workbench user can toggle, select, and inspect a generated marker and
see its lane, OSM, maneuver, and placement provenance.
- [ ] A selected direction marker is geometrically aligned to its directed lane
centerline; a selected turn arrow is on its incoming lane before the junction.
- [ ] Blender scene output and Cesium GLB contain native lane markings and
arrows with the existing visual material language.
- [ ] Unit tests cover a normal supported arrow, an unsupported maneuver, and
an unsafe/too-short placement; native compile and final Nantaizi visual build
pass without publishing `package/`.
## Out Of Scope
- Inventing turn arrows for untagged lanes, traffic-control semantics, changing
QGIS/osm2streets layers, or processing another area.
## Key Decision
- Sharp travel-direction triangles remain a Workbench-only inspection aid.
They explain raw OSM node order after a road is selected.
- Repeated `through` direction arrows are final road markings, distinct from
both those debug triangles and OSM-backed junction turn arrows. They are
placed on native lane centerlines at a fixed interval and enter the same
Blender/Cesium material layer as turn arrows.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-lane-markings",
"name": "native-road-lane-markings",
"title": "Native road lane markings and turn arrows",
"description": "Complete Nantaizi native lane separators, travel direction and turn-arrow geometry through Road Workbench, Blender and Cesium before cross-area migration.",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-14",
"completedAt": null,
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "08-13-native-road-compiler",
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -54,6 +54,9 @@ NATIVE_ROAD_LAYERS = (
{"source": "road_surface", "material_layer": "road_surface"}, {"source": "road_surface", "material_layer": "road_surface"},
{"source": "intersection_surface", "material_layer": "intersection_surface"}, {"source": "intersection_surface", "material_layer": "intersection_surface"},
{"source": "sidewalk_surface", "material_layer": "sidewalks"}, {"source": "sidewalk_surface", "material_layer": "sidewalks"},
{"source": "lane_separators", "material_layer": "lane_separators"},
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
) )

View File

@@ -292,7 +292,7 @@ function buildBlenderScene(area, roadProvider) {
function ensureNativeRoadLayers(area) { function ensureNativeRoadLayers(area) {
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation"); ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson"]) { for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "direction_arrows.geojson", "turn_arrows.geojson"]) {
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`); ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
} }
} }
@@ -304,6 +304,9 @@ function nativeRoadRecords(area) {
nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")), nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")),
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")), nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")), nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
}; };
} }
@@ -313,6 +316,9 @@ function nativeRoadFeatureCounts(area) {
roadSurface: featureCount(path.join(root, "road_surface.geojson")), roadSurface: featureCount(path.join(root, "road_surface.geojson")),
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")), intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")), sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
}; };
} }

View File

@@ -34,7 +34,7 @@ function compileArea(configPath) {
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections }, model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
movements: compiled.movements, movements: compiled.movements,
diagnostics: compiled.diagnostics, diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", connectors: "layers/connectors.geojson" }, layers: { roadSurface: "layers/road_surface.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", connectors: "layers/connectors.geojson" },
}; };
const comparison = compareOsm2Streets(area, result.model, compiled); const comparison = compareOsm2Streets(area, result.model, compiled);
writeJsonAtomic(path.join(staging, "compiled.json"), result); writeJsonAtomic(path.join(staging, "compiled.json"), result);
@@ -44,6 +44,9 @@ function compileArea(configPath) {
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface); writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface); writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines); writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows);
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors); writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true }); fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir); fs.renameSync(staging, area.outputs.nativeRoadDir);
@@ -80,6 +83,9 @@ function compareOsm2Streets(area, model, compiled) {
nativeFallbackJunctions: fallbackJunctions.length, nativeFallbackJunctions: fallbackJunctions.length,
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0), nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length, nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
nativeConnectorFeatures: compiled.connectors.features.length, nativeConnectorFeatures: compiled.connectors.features.length,
nativeMovementCount: compiled.movements.length, nativeMovementCount: compiled.movements.length,
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length, nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,

View File

@@ -2,11 +2,14 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
const OVERRIDE_SCHEMA = "native-road-overrides/v1"; const OVERRIDE_SCHEMA = "native-road-overrides/v1";
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]); const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 }; const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
const DEFAULT_SIDEWALK_WIDTH_METERS = 2; const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
function parseOsmRoads(xml) { function parseOsmRoads(xml) {
const nodes = new Map(); const nodes = new Map();
@@ -233,11 +236,57 @@ function compileGeometry(model, overrides = { overrides: [] }) {
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, 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); const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans); const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics); const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics); validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics }; return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
}
function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
const separators = []; const directionArrows = []; const turnArrows = [];
for (const road of model.roads) {
const roadLanes = 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 ring = roadRing(centerline, 0.12);
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), provenance: "native-road-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane));
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) {
const lane = roadLanes[index]; const explicitManeuver = maneuvers[index];
if (!explicitManeuver) continue;
const maneuver = normalizeManeuver(explicitManeuver);
if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; }
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; }
if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; }
const center = pointAlongLine([...lane.coordinates].reverse(), 6);
const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1);
const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]);
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
const rings = axis ? 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: 6, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
}
return { separators, directionArrows, turnArrows };
}
function directionArrowFeatures(road, lane) {
const length = lineLengthMeters(lane.coordinates);
const features = [];
for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) {
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
if (!placement) continue;
const rings = arrowRingsAt("through", placement.point, placement.axis);
for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
return features;
} }
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) { function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
@@ -582,6 +631,21 @@ function pointAlongLine(line, meters) {
return line.at(-1); return line.at(-1);
} }
function pointAndAxisAlongLine(line, meters) {
let remaining = meters;
for (let index = 1; index < line.length; index += 1) {
const start = line[index - 1]; const end = line[index];
const length = distanceMeters(start, end);
if (length < 0.01) continue;
if (length >= remaining) {
const vector = project(end, start);
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
}
remaining -= length;
}
return null;
}
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) { function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0; const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0; const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;

View File

@@ -358,6 +358,18 @@ function addMeters(center, axis, axisDistance, right, rightDistance, meters) {
]; ];
} }
function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) {
const normalized = normalizeManeuver(maneuver);
if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return [];
const meters = metersForLat(center[1]);
const length = Math.hypot(axis[0], axis[1]);
if (!Number.isFinite(length) || length < 0.001) return [];
const forward = [axis[0] / length, axis[1] / length];
const right = [forward[1], -forward[0]];
return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) =>
addMeters(center, forward, forwardMeters, right, rightMeters, meters)));
}
function templateFor(assetId, manifest = loadManifest()) { function templateFor(assetId, manifest = loadManifest()) {
const asset = supportedAssets(manifest).get(assetId); const asset = supportedAssets(manifest).get(assetId);
if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`); if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`);
@@ -487,4 +499,4 @@ function strokePolygon(points, width) {
return [...left, ...right, left[0]]; return [...left, ...right, left[0]];
} }
module.exports = { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor }; module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };

View File

@@ -48,7 +48,7 @@ function handle(request, response, area, configPath) {
function state(area) { function state(area) {
const nativeDir = area.outputs.nativeRoadDir; const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson"); const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.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")), 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), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.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")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
} }
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); } function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }

View File

@@ -26,6 +26,9 @@ assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coor
assert.equal(geometry.sidewalkSurface.features.length, 2); assert.equal(geometry.sidewalkSurface.features.length, 2);
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5)); assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0)); assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
assert.ok(geometry.connectors.features.length > 0); assert.ok(geometry.connectors.features.length > 0);
assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13)); assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13));
assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id)); assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id));
@@ -40,6 +43,9 @@ const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00
const crossCenter = [114.001, 30]; const crossCenter = [114.001, 30];
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty)); const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
assert.equal(crossGeometry.intersectionSurface.features.length, 1); assert.equal(crossGeometry.intersectionSurface.features.length, 1);
assert.equal(crossGeometry.turnArrows.features.length, 0);
assert.ok(crossGeometry.directionArrows.features.length > 0);
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4)); assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4));
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]); const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5)); assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
@@ -73,6 +79,9 @@ const turnOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001
const turnModel = compileRoadModel(turnOsm, empty); const turnModel = compileRoadModel(turnOsm, empty);
const turnGeometry = compileGeometry(turnModel); const turnGeometry = compileGeometry(turnModel);
assert.equal(turnGeometry.sidewalkSurface.features.length, 0); assert.equal(turnGeometry.sidewalkSurface.features.length, 0);
assert.ok(turnGeometry.directionArrows.features.length > 0);
assert.ok(turnGeometry.turnArrows.features.length > 0);
assert.ok(turnGeometry.turnArrows.features.every((feature) => feature.properties.provenance === "native-road-turn-arrow/v1"));
const sidewalkOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "add-sidewalk", kind: "road", roadId: "road:way/30:forward", sidewalkLeft: true }] }, turnModel); const sidewalkOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "add-sidewalk", kind: "road", roadId: "road:way/30:forward", sidewalkLeft: true }] }, turnModel);
assert.ok(compileGeometry(compileRoadModel(turnOsm, sidewalkOverride)).sidewalkSurface.features.some((feature) => feature.properties.native_id === "sidewalk:way/30:left")); assert.ok(compileGeometry(compileRoadModel(turnOsm, sidewalkOverride)).sidewalkSurface.features.some((feature) => feature.properties.native_id === "sidewalk:way/30:left"));
assert.equal(turnGeometry.connectors.features.length, 1); assert.equal(turnGeometry.connectors.features.length, 1);

View File

@@ -25,4 +25,7 @@ assert.match(app, /function selectJunction\(feature\)/);
assert.match(app, /candidate\.get\("native_id"\) === item\.subjectId/); assert.match(app, /candidate\.get\("native_id"\) === item\.subjectId/);
assert.match(app, /外缘扩张倍率/); assert.match(app, /外缘扩张倍率/);
assert.match(app, /最大外缘扩张/); assert.match(app, /最大外缘扩张/);
assert.match(app, /道路方向箭头/);
assert.match(app, /native-road-direction-arrow\/v1/);
assert.match(app, /term\.textContent = label; detail\.textContent = value; summary\.append\(term, detail\)/);
console.log("road workbench tests passed"); console.log("road workbench tests passed");

View File

@@ -2,7 +2,7 @@
"use strict"; "use strict";
const assert = require("assert"); const assert = require("assert");
const { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows"); const { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
function node(id, lon, lat) { return { id, lon, lat, tags: {} }; } function node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
function way(id, refs, tags) { return { id, refs, tags }; } function way(id, refs, tags) { return { id, refs, tags }; }
@@ -35,6 +35,9 @@ assert.ok(Math.abs((Math.min(...shaftXs) + Math.max(...shaftXs)) / 2) < 1e-9, "t
const rightXs = templateFor("right", manifest).flat().map(([x]) => x); const rightXs = templateFor("right", manifest).flat().map(([x]) => x);
assert.ok(Math.max(...rightXs) - Math.min(...rightXs) < 1.7, "source SVG scale stays comparable to existing lane arrows"); assert.ok(Math.max(...rightXs) - Math.min(...rightXs) < 1.7, "source SVG scale stays comparable to existing lane arrows");
assert.equal(templateFor("through;left;right", manifest).length, 5, "triple maneuver has a shared straight shaft and two branches"); assert.equal(templateFor("through;left;right", manifest).length, 5, "triple maneuver has a shared straight shaft and two branches");
const nativePlacement = arrowRingsAt("left", [114, 30], [0, 1], manifest);
assert.ok(nativePlacement.length > 0 && nativePlacement.every((ring) => ring.every((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]))));
assert.deepEqual(arrowRingsAt("slight_left", [114, 30], [0, 1], manifest), []);
const disabled = buildCustomTurnLaneArrows(osm, { enabled: false, manifest }); const disabled = buildCustomTurnLaneArrows(osm, { enabled: false, manifest });
assert.equal(disabled.features.length, 0); assert.equal(disabled.features.length, 0);
assert.equal(disabled.diagnostics[0].reason, "disabled"); assert.equal(disabled.diagnostics[0].reason, "disabled");

View File

@@ -41,6 +41,11 @@ const dirtyState = document.querySelector("#dirty-state");
const scenePreviewToggle = document.querySelector("#scene-preview"); const scenePreviewToggle = document.querySelector("#scene-preview");
const selectedJunctionPanel = document.querySelector("#selected-junction"); const selectedJunctionPanel = document.querySelector("#selected-junction");
const junctionDetail = document.querySelector("#junction-detail"); const junctionDetail = document.querySelector("#junction-detail");
const directionArrowsToggle = document.createElement("label");
directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头';
const markingsToggle = document.createElement("label");
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle);
let state; let state;
let selectedRoad = null; let selectedRoad = null;
@@ -57,16 +62,18 @@ const layers = {
sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }), sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }),
osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }), osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }),
lanes: new VectorLayer({ source: source(), style: laneStyle }), lanes: new VectorLayer({ source: source(), style: laneStyle }),
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
markings: new VectorLayer({ source: source(), style: markingStyle }),
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }), osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }), connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }), diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }), selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }), selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
}; };
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.sidewalks, layers.osm, layers.lanes, 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.reference, layers.native, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, 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.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) }); const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) });
map.addInteraction(select); map.addInteraction(select);
select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const junction = junctionForFeature(feature); if (junction) return selectJunction(junction); const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null; selectRoad(roadForFeature(feature), undefined, movement); }); select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const junction = junctionForFeature(feature); if (junction) return selectJunction(junction); const marking = feature.get("provenance")?.startsWith("native-road-"); if (marking) { const road = roadForFeature(feature); const directionArrow = feature.get("provenance") === "native-road-direction-arrow/v1"; const turnArrow = feature.get("provenance") === "native-road-turn-arrow/v1"; const markingType = directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线"; selectRoad(road); evidence.textContent = JSON.stringify({ 标线类型: markingType, OSM道路: feature.get("osm_way_ids"), 方向: feature.get("direction"), 车道: feature.get("lane_index") || `${feature.get("left_lane_index")}${feature.get("right_lane_index")} 之间`, 转向: turnArrow ? feature.get("maneuver") : null, 道路内距离米: feature.get("distance_along_lane_meters") || null, 路口前距离米: feature.get("placement_distance_meters") || null, 来源: feature.get("provenance") }, null, 2); return message(`已选中${markingType}`); } const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null; selectRoad(roadForFeature(feature), undefined, movement); });
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; }); map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
function message(text) { status.textContent = text; } function message(text) { status.textContent = text; }
@@ -82,6 +89,7 @@ function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.sli
function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); } function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; } function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); } function laneStyle(feature) { const 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 markingStyle() { return new Style({ fill: new Fill({ color: "#f5f6ee" }), stroke: new Stroke({ color: "#d9dacf", width: 1 }) }); }
function nativeSurfaceStyle(feature) { function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines // Split road features meet at OSM junction nodes. Their per-feature outlines
// are editing aids, not physical seams, so scene mode must render fills only. // are editing aids, not physical seams, so scene mode must render fills only.
@@ -114,6 +122,8 @@ function updateSources() {
layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface)); layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface));
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures()); layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines)); layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines));
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.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors)); layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors));
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) })); layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 }); const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
@@ -164,7 +174,7 @@ addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad,
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); } function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; } function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; }
function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}${counts[filter]}`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } } function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}${counts[filter]}`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["普通构面路口", comparison.nativeApproachEnvelopeJunctions], ["兜底构面路口", comparison.nativeFallbackJunctions], ["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } } function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["普通构面路口", comparison.nativeApproachEnvelopeJunctions], ["兜底构面路口", comparison.nativeFallbackJunctions], ["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio], ["道路方向箭头", comparison.nativeDirectionArrowFeatures], ["路口转向箭头", comparison.nativeTurnArrowFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } }
function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); } function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); }
form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); }; form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; } async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }