feat: add native road control markings
This commit is contained in:
@@ -414,6 +414,77 @@ GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
|
||||
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
|
||||
`turn_arrows.geojson`。工作台用两个开关呈现,Blender 复用同一现有箭头材质。
|
||||
|
||||
## Native 控制标线
|
||||
|
||||
### 1. 范围与触发条件
|
||||
|
||||
`node scripts/compile-native-roads.js --config <area>` 为 native road provider
|
||||
生成 `crosswalks.geojson` 和 `vehicle_stop_lines.geojson`。这是原生道路的独立
|
||||
产物,禁止读取 osm2streets 的渲染图层作为几何输入。
|
||||
|
||||
### 2. 调用形式
|
||||
|
||||
```bash
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
```
|
||||
|
||||
工作台 `GET /api/state` 通过 `layers.crosswalks` 和
|
||||
`layers.vehicleStopLines` 返回两个 FeatureCollection。
|
||||
|
||||
### 3. 契约
|
||||
|
||||
- 只有 `highway=crossing` 且 `crossing:markings` 不是 `no`、`none` 或
|
||||
`unmarked` 的 OSM 节点可以生成斑马线。每个安全匹配点生成六条 stripe,带
|
||||
`crossing_node_id`、`road_id`、`lane_id`、`osm_way_ids`、`direction`、
|
||||
`placement_method` 和 `native-road-crosswalk/v1` provenance。
|
||||
- 停止线还必须对应一个启用的 native arrival endpoint,且过街节点位于该进口到
|
||||
路口的安全距离内;其 provenance 是 `native-road-stop-line/v1`。不能确认进口
|
||||
时保留斑马线并写 `crossing-no-safe-stop-line` diagnostic,不得猜测一条线。
|
||||
- `catalog.NATIVE_ROAD_LAYERS` 将两个源层映射到现有的 `crosswalks` 和
|
||||
`vehicle_stop_lines` 材质层。不得把它们加入 legacy `SCENE_LAYERS`。
|
||||
- 控制标线优先于箭头:默认直行箭头与其相交时跳过;路口转向箭头依次尝试在距路口
|
||||
6、10、14、18、22 米处放置,均冲突时记录 `turn-arrow-control-conflict`。
|
||||
|
||||
### 4. 校验与错误矩阵
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 标记过街没有可匹配 native lane | `crossing-no-native-lane`,不生成任何控制标线 |
|
||||
| 有横道但没有安全进口方向 | 生成斑马线,记录 `crossing-no-safe-stop-line`,不生成停止线 |
|
||||
| 箭头与任一控制标线相交 | 直行箭头跳过;转向箭头后移或记录冲突 diagnostic |
|
||||
| native Blender 输入缺任一控制图层 | `ensureNativeRoadLayers()` 在启动 Blender 前失败 |
|
||||
|
||||
### 5. 正常、基础与错误示例
|
||||
|
||||
- 正常:一个靠近路口的 marked crossing 生成 6 条斑马线和 1 条进口停止线。
|
||||
- 基础:一条孤立的 marked crossing 可以生成斑马线,但不能凭邻近道路方向臆造停止线。
|
||||
- 错误:先生成箭头再叠加停止线,导致两者重叠;控制标线是道路控制语义,必须优先。
|
||||
|
||||
### 6. 必需测试
|
||||
|
||||
- `npm run test:native-road`:断言 marked / unmarked / 无 native lane 的输出,停止线的
|
||||
provenance,以及箭头遇控制标线时后移。
|
||||
- `npm run test:road-workbench`:断言 controls 开关、两条 API layer 和中文选中溯源。
|
||||
- `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`:核查两层
|
||||
feature count 及每条停止线都是 native arrival direction。
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:不运行 `package`。
|
||||
|
||||
### 7. 错误与正确写法
|
||||
|
||||
错误:以最近任意方向车道和固定正向偏移生成停止线。
|
||||
|
||||
```js
|
||||
const stopCenter = offsetByMeters(nearestLane.point, nearestLane.axis, 2.7);
|
||||
```
|
||||
|
||||
正确:先确认该方向的终点是一个已启用的路口 arrival,再在人行横道的上游生成停止线。
|
||||
|
||||
```js
|
||||
const approach = candidates.find((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`));
|
||||
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -2.7);
|
||||
```
|
||||
|
||||
## 斑马线与停止线来源
|
||||
|
||||
### 1. 范围与触发条件
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"08-14-native-road-lane-markings"
|
||||
"08-14-native-road-lane-markings",
|
||||
"08-17-native-road-control-markings"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
|
||||
@@ -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."}
|
||||
66
.trellis/tasks/08-17-native-road-control-markings/design.md
Normal file
66
.trellis/tasks/08-17-native-road-control-markings/design.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Native Road Control Markings Design
|
||||
|
||||
## Architecture
|
||||
|
||||
The native compiler remains the source of truth. It extends its canonical OSM
|
||||
parse with marked crossing nodes, then derives two additive polygon layers from
|
||||
the crossing evidence, native directed lanes, and native junction plans:
|
||||
|
||||
```text
|
||||
OSM crossing node + native directed lane centerlines + junction plans
|
||||
|
|
||||
+-- crosswalks.geojson: six zebra stripe polygons per safe crossing
|
||||
|
|
||||
+-- vehicle_stop_lines.geojson: one safe approach stop-line polygon
|
||||
```
|
||||
|
||||
No osm2streets GeoJSON is read. Existing crossing geometry helpers may be
|
||||
extracted or adapted only when they operate on native lane data and retain
|
||||
native provenance.
|
||||
|
||||
## Source And Placement
|
||||
|
||||
- A source node is eligible only when `highway=crossing` and
|
||||
`crossing:markings` is not `no`, `none`, or `unmarked`.
|
||||
- The compiler finds native roads containing the crossing's OSM node and uses
|
||||
the nearest compatible directed lane centerline to obtain the road tangent.
|
||||
- Crosswalk stripes are perpendicular to that tangent and constrained to the
|
||||
native road width. Existing fixed zebra dimensions are retained initially:
|
||||
six 0.45m stripes with 0.45m gaps, 0.45m stripe width, and a highway-based
|
||||
stripe length.
|
||||
- A stop line is generated only when the crossing can be associated with a
|
||||
supported junction approach and a safe outside-of-junction side. Otherwise
|
||||
the crosswalk may remain valid but the missing stop line is diagnostic.
|
||||
- Duplicate nearby crossing nodes use a stable cluster representative so one
|
||||
physical crosswalk does not produce duplicate stripes.
|
||||
|
||||
## Contracts
|
||||
|
||||
`layers/crosswalks.geojson` and `layers/vehicle_stop_lines.geojson` are Polygon
|
||||
FeatureCollections. Each feature records its crossing OSM node, source OSM way,
|
||||
native directed road/lane when available, direction, placement method, and
|
||||
`native-road-crosswalk/v1` or `native-road-stop-line/v1` provenance.
|
||||
|
||||
The Workbench API returns both layers as `state.layers.crosswalks` and
|
||||
`state.layers.vehicleStopLines`. Its browser map uses separate toggleable
|
||||
layers and selection evidence; the scene-preview toggle leaves real control
|
||||
markings visible.
|
||||
|
||||
`catalog.NATIVE_ROAD_LAYERS` maps the sources to existing `crosswalks` and
|
||||
`vehicle_stop_lines` material layers. This native adapter must not add them to
|
||||
the osm2streets `ROAD_LAYERS` / `SCENE_LAYERS` registry.
|
||||
|
||||
## Compatibility And Rollback
|
||||
|
||||
The new files are additive under `native-road/layers/`. Existing osm2streets
|
||||
output and QGIS input are unchanged. Selecting `--road-provider osm2streets`
|
||||
remains rollback. A native Blender build treats a missing new layer as an error
|
||||
rather than silently omitting a visible marking.
|
||||
|
||||
## Risks
|
||||
|
||||
- Crossing nodes can be detached from a routable road or lie on an ambiguous
|
||||
multi-road segment. These become diagnostics rather than guessed geometry.
|
||||
- A physical crossing split into several OSM nodes must deduplicate stably.
|
||||
- A crosswalk near a non-supported junction may get stripes but no valid stop
|
||||
line; this difference must be exposed in workbench provenance.
|
||||
@@ -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."}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Extend the native OSM parse/model with marked crossing-node evidence while
|
||||
preserving existing road IDs and parser behavior.
|
||||
2. Add native crossing clustering, tangent resolution from directed lane
|
||||
centerlines, stripe geometry, safe stop-line placement, and source
|
||||
diagnostics.
|
||||
3. Persist the two new layers in `compile-native-roads.js`, comparison counts,
|
||||
native build records, required native layer checks, and existing Blender
|
||||
material mappings.
|
||||
4. Add Workbench API/state fields, Chinese layer toggles, selection evidence,
|
||||
and summary counts while retaining scene-preview behavior.
|
||||
5. Add focused fixtures for marked and unmarked crossings, duplicate cluster
|
||||
handling, a missing native-lane diagnostic, and output layer contracts.
|
||||
6. Run native/unit/workbench/build-stage tests, compile/check Nantaizi, then
|
||||
build `blender,cesium,preview --road-provider native` without `package/`.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run test:native-road
|
||||
npm run test:road-workbench
|
||||
npm run test:build-stages
|
||||
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||
npm run road:check -- --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 legacy output path is
|
||||
changed.
|
||||
61
.trellis/tasks/08-17-native-road-control-markings/prd.md
Normal file
61
.trellis/tasks/08-17-native-road-control-markings/prd.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Native road control markings
|
||||
|
||||
## Goal
|
||||
|
||||
Give Nantaizi's native-road provider inspectable, source-traceable crosswalk
|
||||
and vehicle stop-line geometry, so intersection control markings do not depend
|
||||
on the osm2streets render output.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- Native road output already owns road surfaces, sidewalks, lane separators,
|
||||
repeated direction arrows, and explicit junction-turn arrows.
|
||||
- Nantaizi's OSM input contains explicit marked crossings, including zebra and
|
||||
traffic-signal crossings. The existing osm2streets output currently has 48
|
||||
crosswalk-stripe polygons and 8 stop-line polygons.
|
||||
- `build-osm2streets-qgis.js:1067` derives these markings from crossing nodes
|
||||
plus osm2streets driving lanes. Native must not read that rendered geometry;
|
||||
it can reuse only the tested geometry rules after adapting them to native
|
||||
directed lanes and junction plans.
|
||||
- Existing Blender materials already provide `crosswalks` and
|
||||
`vehicle_stop_lines`; native may map into them without changing the legacy
|
||||
osm2streets layer registry.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Native compilation uses explicit marked OSM crossing nodes only, and
|
||||
emits crosswalk-stripe polygons plus approach stop lines only where safe
|
||||
native directed-road placement exists.
|
||||
- R2: Each generated feature preserves crossing node, OSM road, native road or
|
||||
lane, direction, placement method, and relevant junction provenance.
|
||||
- R3: The Road Workbench independently toggles, selects, and describes native
|
||||
crosswalks and stop lines in Chinese.
|
||||
- R4: Native Blender and Cesium builds consume both layers with the existing
|
||||
control-marking materials; no `package/` publication is part of validation.
|
||||
- R5: Missing compatible road context, ambiguous geometry, and unsupported
|
||||
crossing inputs are diagnostics; the compiler must not invent a crossing.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Nantaizi native output contains valid `crosswalks.geojson` and
|
||||
`vehicle_stop_lines.geojson` features with source-traceable properties,
|
||||
without reading osm2streets rendered layers.
|
||||
- [ ] A Workbench user can toggle and select either marking type and see the
|
||||
crossing node, associated road/direction, and placement evidence.
|
||||
- [ ] Native Blender/Cesium output contains both marking types using existing
|
||||
materials, after `blender,cesium,preview --road-provider native` and without
|
||||
publishing `package/`.
|
||||
- [ ] Tests cover a marked crossing, an unmarked crossing skip, a missing or
|
||||
ambiguous native-road placement skip, and output-layer contract checks.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Hand-placed control-marking overrides, traffic-signal state-machine changes,
|
||||
freehand polygon editing, processing another region, and importing
|
||||
osm2streets-rendered crosswalk geometry.
|
||||
|
||||
## Key Decision
|
||||
|
||||
The first version is explicit-OSM-only. This is feasible for Nantaizi and
|
||||
keeps control markings evidence-backed; missing data remains a diagnostic for
|
||||
OSM improvement rather than a silent geometric guess.
|
||||
26
.trellis/tasks/08-17-native-road-control-markings/task.json
Normal file
26
.trellis/tasks/08-17-native-road-control-markings/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "native-road-control-markings",
|
||||
"name": "native-road-control-markings",
|
||||
"title": "Native road control markings",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-17",
|
||||
"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": {}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ NATIVE_ROAD_LAYERS = (
|
||||
{"source": "lane_separators", "material_layer": "lane_separators"},
|
||||
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
|
||||
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
|
||||
{"source": "crosswalks", "material_layer": "crosswalks"},
|
||||
{"source": "vehicle_stop_lines", "material_layer": "vehicle_stop_lines"},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ function buildBlenderScene(area, roadProvider) {
|
||||
|
||||
function ensureNativeRoadLayers(area) {
|
||||
ensureFile(path.join(area.outputs.nativeRoadDir, "compiled.json"), "Native road compilation");
|
||||
for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "direction_arrows.geojson", "turn_arrows.geojson"]) {
|
||||
for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "direction_arrows.geojson", "turn_arrows.geojson", "crosswalks.geojson", "vehicle_stop_lines.geojson"]) {
|
||||
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,8 @@ function nativeRoadRecords(area) {
|
||||
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
|
||||
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
|
||||
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
|
||||
nativeCrosswalks: fileRecord(path.join(root, "crosswalks.geojson")),
|
||||
nativeVehicleStopLines: fileRecord(path.join(root, "vehicle_stop_lines.geojson")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,6 +321,8 @@ function nativeRoadFeatureCounts(area) {
|
||||
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
|
||||
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
|
||||
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
|
||||
crosswalks: featureCount(path.join(root, "crosswalks.geojson")),
|
||||
vehicleStopLines: featureCount(path.join(root, "vehicle_stop_lines.geojson")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function compileArea(configPath) {
|
||||
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
|
||||
movements: compiled.movements,
|
||||
diagnostics: compiled.diagnostics,
|
||||
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" },
|
||||
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", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" },
|
||||
};
|
||||
const comparison = compareOsm2Streets(area, result.model, compiled);
|
||||
writeJsonAtomic(path.join(staging, "compiled.json"), result);
|
||||
@@ -47,6 +47,8 @@ function compileArea(configPath) {
|
||||
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", "crosswalks.geojson"), compiled.crosswalks);
|
||||
writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines);
|
||||
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
|
||||
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
|
||||
fs.renameSync(staging, area.outputs.nativeRoadDir);
|
||||
@@ -86,6 +88,8 @@ function compareOsm2Streets(area, model, compiled) {
|
||||
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
|
||||
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
|
||||
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
|
||||
nativeCrosswalkFeatures: compiled.crosswalks.features.length,
|
||||
nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length,
|
||||
nativeConnectorFeatures: compiled.connectors.features.length,
|
||||
nativeMovementCount: compiled.movements.length,
|
||||
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
|
||||
|
||||
@@ -10,14 +10,20 @@ const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, ter
|
||||
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
|
||||
const DIRECTION_ARROW_INTERVAL_METERS = 32;
|
||||
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
|
||||
const STOP_LINE_OFFSET_METERS = 2.7;
|
||||
const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25;
|
||||
|
||||
function parseOsmRoads(xml) {
|
||||
const nodes = new Map();
|
||||
const crossingNodes = [];
|
||||
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
||||
if (coordinate.every(Number.isFinite)) nodes.set(String(attrs.id), coordinate);
|
||||
if (!coordinate.every(Number.isFinite)) continue;
|
||||
const id = String(attrs.id); const tags = parseTags(match[2] || "");
|
||||
nodes.set(id, coordinate);
|
||||
if (tags.highway === "crossing" && !["no", "none", "unmarked"].includes(tags["crossing:markings"])) crossingNodes.push({ id, coordinate, tags });
|
||||
}
|
||||
const ways = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
@@ -30,7 +36,7 @@ function parseOsmRoads(xml) {
|
||||
if (coords.length < 2 || coords.length !== refs.length) continue;
|
||||
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
|
||||
}
|
||||
return { nodes, ways };
|
||||
return { nodes, ways, crossingNodes };
|
||||
}
|
||||
|
||||
function compileRoadModel(xml, overrides) {
|
||||
@@ -72,7 +78,8 @@ function compileRoadModel(xml, overrides) {
|
||||
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id, manualCandidates: nearbyManualCandidates(endpoints, endpoint) });
|
||||
}
|
||||
}
|
||||
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
|
||||
const crossings = parsed.crossingNodes.map((crossing) => ({ ...crossing, osmWayIds: parsed.ways.filter((way) => way.refs.includes(crossing.id)).map((way) => way.id) }));
|
||||
return { schema: "native-road-model/v1", roads, endpoints, connections, crossings, diagnostics };
|
||||
}
|
||||
|
||||
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
|
||||
@@ -236,16 +243,45 @@ 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] } });
|
||||
}
|
||||
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
|
||||
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans);
|
||||
const controls = compileControlMarkings(model, lanes, diagnostics);
|
||||
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls);
|
||||
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
|
||||
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
|
||||
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, 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 }, 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 };
|
||||
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 }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
|
||||
}
|
||||
|
||||
function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
function compileControlMarkings(model, lanes, diagnostics) {
|
||||
const crosswalks = []; const stopLines = [];
|
||||
const arrivalEndpointIds = new Set(model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId));
|
||||
for (const crossing of model.crossings || []) {
|
||||
const candidates = model.roads.filter((road) => crossing.osmWayIds.includes(road.osmWayIds[0])).flatMap((road) => (lanes.byRoadId.get(road.id) || []).map((lane) => ({ road, lane, placement: nearestLanePlacement(lane.coordinates, crossing.coordinate), junctionDistanceMeters: distanceMeters(crossing.coordinate, road.centerline.at(-1)) })).filter((item) => item.placement));
|
||||
const candidate = candidates.sort((a, b) => a.placement.distance - b.placement.distance)[0];
|
||||
if (!candidate || candidate.placement.distance > 12) { diagnostics.push(diagnostic("warning", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-native-lane", "人行横道无法匹配到安全的原生车道,未生成标线。", crossing.coordinate)); continue; }
|
||||
const approach = candidates.filter((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`) && item.junctionDistanceMeters > STOP_LINE_OFFSET_METERS && item.junctionDistanceMeters <= STOP_LINE_MAX_APPROACH_DISTANCE_METERS).sort((a, b) => a.junctionDistanceMeters - b.junctionDistanceMeters || a.placement.distance - b.placement.distance)[0];
|
||||
const crosswalkCandidate = approach || candidate;
|
||||
const { axis } = crosswalkCandidate.placement; const across = [-axis[1], axis[0]];
|
||||
for (let index = 0; index < 6; index += 1) crosswalks.push(controlFeature("crosswalk", crossing, crosswalkCandidate, index + 1, rectangleAt(crossing.coordinate, axis, across, 3, .45, -2.25 + index * .9)));
|
||||
if (!approach) { diagnostics.push(diagnostic("info", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-safe-stop-line", "人行横道没有可确认的路口进口车道,保留斑马线但未生成停止线。", crossing.coordinate)); continue; }
|
||||
const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, crossing.coordinate);
|
||||
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
|
||||
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
|
||||
const laneCenterAtCrossing = offsetByMeters(crossing.coordinate, across, lateralOffset);
|
||||
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS);
|
||||
stopLines.push(controlFeature("stop-line", crossing, approach, 1, rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, .45, 0)));
|
||||
}
|
||||
return { crosswalks, stopLines };
|
||||
}
|
||||
|
||||
function nearestLanePlacement(line, target) { let best = null; let traversedMeters = 0; for (let index = 1; index < line.length; index += 1) { const a = line[index - 1]; const b = line[index]; const vector = project(b, a); const length = Math.hypot(...vector); if (!length) continue; const relative = project(target, a); const ratio = Math.max(0, Math.min(1, (relative[0] * vector[0] + relative[1] * vector[1]) / (length * length))); const point = interpolate(a, b, ratio); const distance = distanceMeters(point, target); if (!best || distance < best.distance) best = { point, axis: [vector[0] / length, vector[1] / length], distance, distanceToEndMeters: lineLengthMeters(line) - traversedMeters - length * ratio }; traversedMeters += length; } return best; }
|
||||
function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meters, axis[1] * meters], point); }
|
||||
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) { 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", provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; }
|
||||
|
||||
function compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls) {
|
||||
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) || [];
|
||||
for (let index = 1; index < roadLanes.length; index += 1) {
|
||||
@@ -255,7 +291,7 @@ function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
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));
|
||||
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics));
|
||||
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) {
|
||||
@@ -265,30 +301,44 @@ function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
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) : [];
|
||||
const placement = axis ? [6, 10, 14, 18, 22].find((distance) => distance < lineLengthMeters(lane.coordinates) - 2 && !ringsOverlapControl(arrowRingsAt(maneuver, pointAlongLine([...lane.coordinates].reverse(), distance), axis), controlFeatures)) : null;
|
||||
if (!placement) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-control-conflict", "转向箭头会压住斑马线或停止线,未生成该箭头。", lane.coordinates.at(-1))); continue; }
|
||||
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: 6, 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, 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 directionArrowFeatures(road, lane) {
|
||||
function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
|
||||
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);
|
||||
if (ringsOverlapControl(rings, controlFeatures)) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "direction-arrow-control-conflict", "默认直行箭头会压住斑马线或停止线,已跳过该位置。", placement.point)); continue; }
|
||||
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 ringsOverlapControl(rings, controls) {
|
||||
return rings.some((ring) => controls.some((feature) => ringsOverlap(ring, feature.geometry.coordinates[0])));
|
||||
}
|
||||
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);
|
||||
if (a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]) return false;
|
||||
if (first.some((point) => pointInPolygon(point, second)) || second.some((point) => pointInPolygon(point, first))) return true;
|
||||
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) {
|
||||
const features = [];
|
||||
const byWay = new Map();
|
||||
|
||||
@@ -48,7 +48,7 @@ function handle(request, response, area, configPath) {
|
||||
function state(area) {
|
||||
const nativeDir = area.outputs.nativeRoadDir;
|
||||
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")), 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 } };
|
||||
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")), 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 readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
|
||||
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||
|
||||
@@ -39,6 +39,17 @@ assert.ok(geometry.intersectionSurface.features.every((feature) => feature.prope
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => ["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"));
|
||||
const controlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00080" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><node id="4" lon="114.002" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="unmarked"/></node><node id="5" lon="114.0035" lat="30"><tag k="highway" v="crossing"/></node><node id="6" lon="114.004" lat="30"/><node id="7" lon="114.001" lat="30.001"/><way id="60"><nd ref="1"/><nd ref="2"/><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="61"><nd ref="5"/><nd ref="6"/><tag k="highway" v="footway"/></way><way id="62"><nd ref="3"/><nd ref="7"/><tag k="highway" v="residential"/></way></osm>`;
|
||||
const controlGeometry = compileGeometry(compileRoadModel(controlOsm, empty));
|
||||
assert.equal(controlGeometry.crosswalks.features.length, 6);
|
||||
assert.equal(controlGeometry.vehicleStopLines.features.length, 1);
|
||||
assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-crosswalk/v1"));
|
||||
assert.ok(controlGeometry.vehicleStopLines.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-stop-line/v1"));
|
||||
assert.ok(controlGeometry.diagnostics.some((item) => item.rule === "crossing-no-native-lane" && item.sourceIds.includes("5")));
|
||||
const arrowControlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00095" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><way id="63"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/><tag k="lanes" v="1"/><tag k="turn:lanes" v="through"/></way></osm>`;
|
||||
const arrowControlGeometry = compileGeometry(compileRoadModel(arrowControlOsm, empty));
|
||||
assert.ok(arrowControlGeometry.turnArrows.features.length > 0);
|
||||
assert.ok(arrowControlGeometry.turnArrows.features.every((feature) => feature.properties.placement_distance_meters > 6));
|
||||
const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><node id="5" lon="114.001" lat="29.999"/><way id="40"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="41"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="42"><nd ref="5"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="43"><nd ref="2"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
|
||||
const crossCenter = [114.001, 30];
|
||||
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
|
||||
@@ -104,6 +115,8 @@ try {
|
||||
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
|
||||
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
|
||||
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
|
||||
assert.equal(compiledArea.comparison.nativeCrosswalkFeatures, 0);
|
||||
assert.equal(compiledArea.comparison.nativeVehicleStopLineFeatures, 0);
|
||||
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
|
||||
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
|
||||
assert.equal(checkArea(config).ok, true);
|
||||
|
||||
@@ -27,5 +27,11 @@ assert.match(app, /外缘扩张倍率/);
|
||||
assert.match(app, /最大外缘扩张/);
|
||||
assert.match(app, /道路方向箭头/);
|
||||
assert.match(app, /native-road-direction-arrow\/v1/);
|
||||
assert.match(app, /data-layer="controls" type="checkbox" checked> 斑马线与停止线/);
|
||||
assert.match(app, /controls: new VectorLayer/);
|
||||
assert.match(app, /state\.layers\.crosswalks/);
|
||||
assert.match(app, /state\.layers\.vehicleStopLines/);
|
||||
assert.match(app, /native-road-crosswalk\/v1/);
|
||||
assert.match(app, /native-road-stop-line\/v1/);
|
||||
assert.match(app, /term\.textContent = label; detail\.textContent = value; summary\.append\(term, detail\)/);
|
||||
console.log("road workbench tests passed");
|
||||
|
||||
@@ -45,7 +45,9 @@ 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);
|
||||
const controlsToggle = document.createElement("label");
|
||||
controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked> 斑马线与停止线';
|
||||
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, controlsToggle);
|
||||
|
||||
let state;
|
||||
let selectedRoad = null;
|
||||
@@ -64,16 +66,49 @@ const layers = {
|
||||
lanes: new VectorLayer({ source: source(), style: laneStyle }),
|
||||
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
|
||||
markings: new VectorLayer({ source: source(), style: markingStyle }),
|
||||
controls: 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 }),
|
||||
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 }) }) }) }),
|
||||
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.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.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)" }) }) });
|
||||
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.controls, 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.controls, 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);
|
||||
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); });
|
||||
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 provenance = feature.get("provenance");
|
||||
if (provenance?.startsWith("native-road-")) {
|
||||
const road = roadForFeature(feature);
|
||||
const directionArrow = provenance === "native-road-direction-arrow/v1";
|
||||
const turnArrow = provenance === "native-road-turn-arrow/v1";
|
||||
const crosswalk = provenance === "native-road-crosswalk/v1";
|
||||
const stopLine = provenance === "native-road-stop-line/v1";
|
||||
const markingType = crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
|
||||
selectRoad(road);
|
||||
evidence.textContent = JSON.stringify({
|
||||
标线类型: markingType,
|
||||
人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null,
|
||||
OSM道路: feature.get("osm_way_ids"),
|
||||
原生道路: feature.get("road_id"),
|
||||
方向: feature.get("direction"),
|
||||
车道: feature.get("lane_id") || feature.get("lane_index") || `${feature.get("left_lane_index")} 与 ${feature.get("right_lane_index")} 之间`,
|
||||
转向: turnArrow ? feature.get("maneuver") : null,
|
||||
放置方法: feature.get("placement_method") || null,
|
||||
道路内距离米: feature.get("distance_along_lane_meters") || null,
|
||||
路口前距离米: feature.get("placement_distance_meters") || null,
|
||||
来源: 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" : ""; });
|
||||
|
||||
function message(text) { status.textContent = text; }
|
||||
@@ -124,6 +159,7 @@ function updateSources() {
|
||||
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.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
|
||||
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 })) }));
|
||||
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
|
||||
@@ -174,7 +210,31 @@ 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 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 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 renderSummary() {
|
||||
const comparison = state.comparison;
|
||||
const rows = [
|
||||
["方向道路", comparison.nativeRoadCount],
|
||||
["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures],
|
||||
["路口面", comparison.nativeJunctionSurfaceFeatures],
|
||||
["普通构面路口", comparison.nativeApproachEnvelopeJunctions],
|
||||
["兜底构面路口", comparison.nativeFallbackJunctions],
|
||||
["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio],
|
||||
["道路方向箭头", comparison.nativeDirectionArrowFeatures],
|
||||
["路口转向箭头", comparison.nativeTurnArrowFeatures],
|
||||
["斑马线条带", comparison.nativeCrosswalkFeatures],
|
||||
["停止线", comparison.nativeVehicleStopLineFeatures],
|
||||
["行驶动作", comparison.nativeMovementCount],
|
||||
["已绘制路径", comparison.nativePublishedMovementCount],
|
||||
["可手工复核", comparison.unconnectedEndsWithManualCandidates],
|
||||
["内部断头", comparison.unconnectedInteriorRoadEnds],
|
||||
["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 }); }
|
||||
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; }
|
||||
@@ -190,6 +250,7 @@ scenePreviewToggle.onchange = () => {
|
||||
layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked);
|
||||
layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked);
|
||||
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
|
||||
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
|
||||
layers.diagnostics.setVisible(!scenePreview);
|
||||
layers.native.changed(); layers.sidewalks.changed();
|
||||
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
|
||||
|
||||
Reference in New Issue
Block a user