feat: add native road control markings

This commit is contained in:
2026-08-17 10:01:44 +08:00
parent ea8a3622b9
commit 4c4f4534c0
16 changed files with 418 additions and 20 deletions

View File

@@ -414,6 +414,77 @@ GeoJSONnative Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入 正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现Blender 复用同一现有箭头材质。 `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. 范围与触发条件 ### 1. 范围与触发条件

View File

@@ -19,7 +19,8 @@
"pr_url": null, "pr_url": null,
"subtasks": [], "subtasks": [],
"children": [ "children": [
"08-14-native-road-lane-markings" "08-14-native-road-lane-markings",
"08-17-native-road-control-markings"
], ],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],

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,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.

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. 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.

View 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.

View 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": {}
}

View File

@@ -57,6 +57,8 @@ NATIVE_ROAD_LAYERS = (
{"source": "lane_separators", "material_layer": "lane_separators"}, {"source": "lane_separators", "material_layer": "lane_separators"},
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"}, {"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "turn_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"},
) )

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", "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}`); 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")), nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")), nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
nativeTurnArrows: fileRecord(path.join(root, "turn_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")), laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")), directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
turnArrows: featureCount(path.join(root, "turn_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")),
}; };
} }

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", 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); const comparison = compareOsm2Streets(area, result.model, compiled);
writeJsonAtomic(path.join(staging, "compiled.json"), result); 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", "lane_separators.geojson"), compiled.laneSeparators);
writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows); 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", "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); 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);
@@ -86,6 +88,8 @@ function compareOsm2Streets(area, model, compiled) {
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length, nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
nativeDirectionArrowFeatures: compiled.directionArrows.features.length, nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
nativeTurnArrowFeatures: compiled.turnArrows.features.length, nativeTurnArrowFeatures: compiled.turnArrows.features.length,
nativeCrosswalkFeatures: compiled.crosswalks.features.length,
nativeVehicleStopLineFeatures: compiled.vehicleStopLines.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

@@ -10,14 +10,20 @@ const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, ter
const DEFAULT_SIDEWALK_WIDTH_METERS = 2; const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32; const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14; 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) { function parseOsmRoads(xml) {
const nodes = new Map(); const nodes = new Map();
const crossingNodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = xmlAttrs(match[1]); const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue; if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)]; 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 = []; const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) { 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; if (coords.length < 2 || coords.length !== refs.length) continue;
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags }); ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
} }
return { nodes, ways }; return { nodes, ways, crossingNodes };
} }
function compileRoadModel(xml, overrides) { 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) }); 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) { 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] } }); 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 controls = compileControlMarkings(model, lanes, diagnostics);
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans, controls);
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 }, 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 separators = []; const directionArrows = []; const turnArrows = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
for (const road of model.roads) { for (const road of model.roads) {
const roadLanes = lanes.byRoadId.get(road.id) || []; const roadLanes = lanes.byRoadId.get(road.id) || [];
for (let index = 1; index < roadLanes.length; index += 1) { 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); 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] } }); 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 turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
const maneuvers = turns ? String(turns).split("|") : []; const maneuvers = turns ? String(turns).split("|") : [];
for (let index = 0; index < roadLanes.length; index += 1) { for (let index = 0; index < roadLanes.length; index += 1) {
@@ -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 (!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 (!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; } 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 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 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 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; 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 }; return { separators, directionArrows, turnArrows };
} }
function directionArrowFeatures(road, lane) { function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
const length = lineLengthMeters(lane.coordinates); const length = lineLengthMeters(lane.coordinates);
const features = []; 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) { 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); const placement = pointAndAxisAlongLine(lane.coordinates, distance);
if (!placement) continue; if (!placement) continue;
const rings = arrowRingsAt("through", placement.point, placement.axis); 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]] } }); 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; 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) { function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
const features = []; const features = [];
const byWay = new Map(); const byWay = new Map();

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")), 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 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

@@ -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) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1)); assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback")); for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
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 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 crossCenter = [114.001, 30];
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty)); const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
@@ -104,6 +115,8 @@ try {
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2"); assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length); 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.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.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0); assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
assert.equal(checkArea(config).ok, true); assert.equal(checkArea(config).ok, true);

View File

@@ -27,5 +27,11 @@ 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, /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\)/); 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

@@ -45,7 +45,9 @@ const directionArrowsToggle = document.createElement("label");
directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头'; directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头';
const markingsToggle = document.createElement("label"); const markingsToggle = document.createElement("label");
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头'; 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 state;
let selectedRoad = null; let selectedRoad = null;
@@ -64,16 +66,49 @@ const layers = {
lanes: new VectorLayer({ source: source(), style: laneStyle }), lanes: new VectorLayer({ source: source(), style: laneStyle }),
directionArrows: new VectorLayer({ source: source(), style: markingStyle }), directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
markings: 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 }), 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.directionArrows, layers.markings, 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.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.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.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); 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" : ""; }); 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; }
@@ -124,6 +159,7 @@ function updateSources() {
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.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.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.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 });
@@ -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 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.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 }); } 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; }
@@ -190,6 +250,7 @@ scenePreviewToggle.onchange = () => {
layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked); layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked);
layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked); layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked);
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked); layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
layers.diagnostics.setVisible(!scenePreview); layers.diagnostics.setVisible(!scenePreview);
layers.native.changed(); layers.sidewalks.changed(); layers.native.changed(); layers.sidewalks.changed();
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览"); message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");