feat: add native road center lines

This commit is contained in:
2026-08-17 10:44:01 +08:00
parent 7dc5c947a3
commit fb863dafb2
18 changed files with 324 additions and 12 deletions

View File

@@ -414,6 +414,83 @@ GeoJSONnative Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现Blender 复用同一现有箭头材质。
## Native 道路中心虚线
### 1. 范围与触发条件
`node scripts/compile-native-roads.js --config <area>` 为可确认的双向非 service
道路段写入 `native-road/layers/center_lines.geojson`。这是原生几何:只能依据
canonical OSM 中心线、native 双方向道路模型和 native 路口 cutback 生成osm2streets
`center_lines.geojson` 只可作为视觉基准,绝不能作为输入。
### 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
npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native
```
工作台 `GET /api/state` 通过 `layers.centerLines` 返回该 FeatureCollectionnative
Blender adapter 必须将 source `center_lines` 映射到既有 `center_lines` material layer。
### 3. 契约
- 每一 dash 均为 Polygon`2m`、宽 `0.25m`、确定性间隔 `2m`,使用
`native-road-center-line/v1` provenance。
- 要素必须含有 `native_id``segment_id``road_id``directional_road_ids`
`osm_way_ids``dash_index``dash_length_m``dash_gap_m``placement_rule`,以便
Workbench 用中文显示“道路中心虚线”并可回溯来源。
- 只有同一 `segment_id` 恰有一条 forward 和一条 backward native road 时才生成;单向、
`highway=service` 或退化中心线都不能伪造中心线。退化中心线须写
`invalid-center-line` diagnostic。
- 线段必须先经过 native junction cutback再与 crosswalk、vehicle stop line 的控制面
求冲突,冲突 dash 直接略去。控制标线优先于中心虚线。
- `compiled.json.layers.centerLines``comparison.json.nativeCenterLineFeatures`
`build-area.js` 的 native input records 与 required-layer 验证必须全部使用
`layers/center_lines.geojson`;缺失时在 Blender 启动前失败,不得静默漏画。
### 4. 校验与错误矩阵
| 条件 | 结果 |
|---|---|
| 可确认的双向普通道路段 | 生成 2m / 0.25m 黄虚线,固定 2m gap |
| 单向或 `highway=service` 道路 | 不生成中心虚线 |
| 中心线不足两点、长度不可用 | `invalid-center-line` diagnostic不写畸形 Polygon |
| dash 进入 junction cutback | trim 后不生成该范围 dash |
| dash 与斑马线或停止线相交 | 不生成冲突 dash |
| native Blender 输入缺 `center_lines.geojson` | `ensureNativeRoadLayers()` 抛错 |
### 5. 正常、基础与错误示例
- 正常:一条有 forward/backward carriageway 的 residential 段在两个方向道路之间生成黄虚线。
- 基础:没有双向证据的道路仍可有车道分隔线,但不产生道路中心虚线。
- 错误:从 osm2streets 图层复制或裁剪中心线;这会将渲染器缺陷重新变成 native 数据依赖。
### 6. 必需测试
- `npm run test:native-road`断言双向生成、provenance、2m 尺寸/间隔、单向和 service 跳过。
- `npm run test:road-workbench`:断言 `centerLines` API、中文开关、选择溯源和概览计数。
- `npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json`:输出合法图层,检查中心线与控制标线不重叠。
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志列出 `center_lines`,且不运行 `package`
### 7. 错误与正确写法
错误:只按一条 directional road 生成中心线,或忽略控制标线。
```js
const line = road.centerline;
features.push(makeDash(line));
```
正确:先确认成对的双方向道路、做路口裁剪,再排除控制标线冲突。
```js
if (roads.length !== 2 || !forward || !backward || forward.highway === "service") continue;
const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
if (!ringsOverlapControl([ring], [...controls.crosswalks, ...controls.stopLines])) features.push(dash);
```
## Native 控制标线
### 1. 范围与触发条件

View File

@@ -20,7 +20,8 @@
"subtasks": [],
"children": [
"08-14-native-road-lane-markings",
"08-17-native-road-control-markings"
"08-17-native-road-control-markings",
"08-17-native-road-center-lines"
],
"parent": null,
"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,50 @@
# Native Road Centre Lines Design
## Architecture
The native compiler adds one independent polygon layer:
```text
canonical bidirectional segment + trimmed OSM centreline
-> repeated 2m x 0.25m dash polygons
-> native-road/layers/center_lines.geojson
-> Workbench / native Blender adapter / Cesium
```
It does not read osm2streets `center_lines.geojson`. The legacy layer is a
visual baseline only.
## Placement
- A candidate must have exactly forward and backward native roads for the same
`segmentId`, neither be `highway=service`, and have a valid trimmed OSM
centreline.
- Dashes use the legacy observed dimensions: 2m length, 0.25m width, with a
deterministic 2m gap. The first dash starts at a fixed segment-local offset
so rebuilds do not drift.
- The line is trimmed with the same `junctionPlans` cutback used by native lane
centreline generation. Any dash intersecting a crosswalk or stop line is
omitted, preserving the control-marking priority already established.
- One-way and service segments have no generated feature. A degenerate source
line produces a diagnostic rather than malformed geometry.
## Contracts
`layers/center_lines.geojson` is a Polygon FeatureCollection. Every feature
has `native_id`, `segment_id`, `directional_road_ids`, `osm_way_ids`,
`dash_index`, `dash_length_m`, `dash_gap_m`, `placement_rule`, and
`provenance="native-road-center-line/v1"`.
`compiled.json.layers.centerLines`, comparison count
`nativeCenterLineFeatures`, and `build-area.js` required native records use the
same filename. `catalog.NATIVE_ROAD_LAYERS` maps source `center_lines` to the
existing `center_lines` material layer.
The Workbench loads this source into the marking overlay, exposes a Chinese
toggle, and identifies it as `道路中心虚线` on selection.
## Compatibility
The change is additive within `native-road/`; `--road-provider osm2streets`
remains unaffected and is rollback. A native Blender build treats a missing
layer as an input error rather than silently omitting it.

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,19 @@
# Implementation Plan
1. Add centre-dash constants and a pure native compiler routine based on the
shared bidirectional segment model and junction-trimmed centreline.
2. Filter invalid, one-way, service, and control-marking-conflicting dashes;
record actionable diagnostics for invalid geometry only.
3. Write `center_lines.geojson`, compilation/comparison counts, native build
records, required-layer checks, and Blender material adapter mapping.
4. Add Workbench API state, Chinese toggle, summary count, rendering, and
source evidence for a selected centre dash.
5. Add focused native and Workbench tests for generation, skips, control
avoidance, provenance, and file/API contracts.
6. Run native/unit/workbench/build-stage tests, compile/check Nantaizi, then
validate `blender,cesium,preview --road-provider native` without `package`.
## Rollback
Choose `--road-provider osm2streets`; no legacy layer or published package is
modified.

View File

@@ -0,0 +1,58 @@
# Native road center lines
## Goal
Restore the existing osm2streets-style road-centre dashed markings in the
native-road provider before treating native output as ready for broader quality
gates or new-area validation.
## Confirmed Facts
- Nantaizi's existing `osm2streets_web_out/center_lines.geojson` has 885
`type="center line"` polygons. Its sampled dash geometry is approximately
2.00m long by 0.25m wide.
- The legacy extraction excludes centre lines that overlap crosswalk zones and
that fall on service driving polygons (`build-osm2streets-qgis.js:474-480`).
- Native output currently has only same-direction `lane_separators`; it has no
`center_lines.geojson`, so a bidirectional road's directional carriageways
lack their visual and semantic divider.
- Existing Blender already owns a `center_lines` / `Center Line` material
layer. Native must adapt to it rather than creating a second scene registry.
## Requirements
- R1: For supported non-service, bidirectional native road segments, emit a
`center_lines.geojson` dashed centre divider derived from the canonical OSM
centreline and the segment's two directional carriageways.
- R2: Match the established visual baseline: yellow 0.25m-wide, 2m-long
dashes, clipped away from ordinary junction cutbacks and control markings.
- R3: Preserve source traceability: each dash records its native segment,
source OSM way, involved directional roads, placement interval/rule, and a
dedicated provenance value.
- R4: Add the layer to compilation records, Road Workbench display/selection
in Chinese, and the existing Blender/Cesium `center_lines` material path.
- R5: Do not render a centre divider on one-way or `highway=service` segments;
report invalid geometry instead of fabricating a divider.
## Acceptance Criteria
- [ ] Native Nantaizi output contains a valid `layers/center_lines.geojson`
with source-traceable 2m x 0.25m dashed polygons.
- [ ] No native centre dash intersects a generated crosswalk or vehicle stop
line, and no dash reaches into the supported junction surface cutback.
- [ ] The Workbench can toggle and select centre lines, presenting their
source and rule in Chinese rather than calling them lane separators.
- [ ] Native `blender,cesium,preview` consumes the layer using the existing
`center_lines` material and does not run `package`.
- [ ] Tests cover two-way generation, one-way/service skips, control-marking
avoidance, provenance, output contract, and Workbench wiring.
## Out Of Scope
- Centre-line editing overrides, solid/double-centre-line semantics, arbitrary
OSM `overtaking` interpretation, lane colouring changes, and other areas.
## Key Decision
This task deliberately matches the existing stable visual layer first. It does
not claim to infer a country-wide legal marking taxonomy from sparse OSM tags.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-center-lines",
"name": "native-road-center-lines",
"title": "Native road center lines",
"description": "Align native-road output with the existing center_lines layer before broader quality work.",
"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

@@ -799,11 +799,11 @@ def build(args):
target = source["material_layer"]
layer = material_layers[target]
source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson")
if not os.path.isfile(source_path):
raise RuntimeError("Native road layer is missing: " + source_path)
count = _roads.assemble_geojson_layer(
source_path, source["source"], projector, roads_c,
road_mats[target], layer["z"])
if count == 0:
raise RuntimeError("Native road layer has no usable geometry: " + source_path)
road_counts[source["source"]] = count
elif geojson_dir and os.path.isdir(geojson_dir):
for problem in catalog.check_layers(geojson_dir):

View File

@@ -55,6 +55,7 @@ NATIVE_ROAD_LAYERS = (
{"source": "intersection_surface", "material_layer": "intersection_surface"},
{"source": "sidewalk_surface", "material_layer": "sidewalks"},
{"source": "lane_separators", "material_layer": "lane_separators"},
{"source": "center_lines", "material_layer": "center_lines"},
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
{"source": "crosswalks", "material_layer": "crosswalks"},

View File

@@ -53,7 +53,13 @@ class RoadLayerCatalogTest(unittest.TestCase):
for entry in NATIVE_ROAD_LAYERS],
[("road_surface", "road_surface"),
("intersection_surface", "intersection_surface"),
("sidewalk_surface", "sidewalks")])
("sidewalk_surface", "sidewalks"),
("lane_separators", "lane_separators"),
("center_lines", "center_lines"),
("direction_arrows", "lane_arrows_webscale"),
("turn_arrows", "lane_arrows_webscale"),
("crosswalks", "crosswalks"),
("vehicle_stop_lines", "vehicle_stop_lines")])
self.assertTrue(all(entry["material_layer"] in layers
for entry in NATIVE_ROAD_LAYERS))

View File

@@ -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", "crosswalks.geojson", "vehicle_stop_lines.geojson"]) {
for (const file of ["road_surface.geojson", "intersection_surface.geojson", "sidewalk_surface.geojson", "lane_separators.geojson", "center_lines.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}`);
}
}
@@ -305,6 +305,7 @@ function nativeRoadRecords(area) {
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
nativeCenterLines: fileRecord(path.join(root, "center_lines.geojson")),
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
nativeCrosswalks: fileRecord(path.join(root, "crosswalks.geojson")),
@@ -319,6 +320,7 @@ function nativeRoadFeatureCounts(area) {
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
centerLines: featureCount(path.join(root, "center_lines.geojson")),
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
crosswalks: featureCount(path.join(root, "crosswalks.geojson")),

View File

@@ -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", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.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", centerLines: "layers/center_lines.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);
@@ -45,6 +45,7 @@ function compileArea(configPath) {
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators);
writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines);
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);
@@ -86,6 +87,7 @@ function compareOsm2Streets(area, model, compiled) {
nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0),
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length,
nativeCenterLineFeatures: compiled.centerLines.features.length,
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
nativeCrosswalkFeatures: compiled.crosswalks.features.length,

View File

@@ -12,6 +12,9 @@ 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;
const CENTER_LINE_DASH_LENGTH_METERS = 2;
const CENTER_LINE_DASH_GAP_METERS = 2;
const CENTER_LINE_WIDTH_METERS = .25;
function parseOsmRoads(xml) {
const nodes = new Map();
@@ -244,12 +247,39 @@ function compileGeometry(model, overrides = { overrides: [] }) {
}
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const controls = compileControlMarkings(model, lanes, diagnostics);
const centerLines = compileCenterLines(model, junctionPlans, controls, 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 }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
}
function compileCenterLines(model, junctionPlans, controls, diagnostics) {
const features = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
const segments = new Map();
for (const road of model.roads) {
if (!segments.has(road.segmentId)) segments.set(road.segmentId, []);
segments.get(road.segmentId).push(road);
}
for (const [segmentId, roads] of segments) {
const forward = roads.find((road) => road.direction === "forward");
const backward = roads.find((road) => road.direction === "backward");
if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue;
const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const length = lineLengthMeters(line);
if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; }
for (let start = 0, dashIndex = 1; start + CENTER_LINE_DASH_LENGTH_METERS <= length; start += CENTER_LINE_DASH_LENGTH_METERS + CENTER_LINE_DASH_GAP_METERS, dashIndex += 1) {
const placement = pointAndAxisAlongLine(line, start + CENTER_LINE_DASH_LENGTH_METERS / 2);
if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], CENTER_LINE_DASH_LENGTH_METERS, CENTER_LINE_WIDTH_METERS, 0);
if (ringsOverlapControl([ring], controlFeatures)) continue;
features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: CENTER_LINE_DASH_LENGTH_METERS, dash_gap_m: CENTER_LINE_DASH_GAP_METERS, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
return features;
}
function compileControlMarkings(model, lanes, diagnostics) {

View File

@@ -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")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), 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")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
}
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }

View File

@@ -27,6 +27,14 @@ assert.equal(geometry.sidewalkSurface.features.length, 2);
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
assert.ok(geometry.centerLines.features.length > 0);
assert.ok(geometry.centerLines.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-center-line/v1" && feature.properties.dash_length_m === 2 && feature.properties.dash_gap_m === 2));
for (const feature of geometry.centerLines.features) {
const ring = feature.geometry.coordinates[0];
const lengths = [distance(ring[0], ring[1]), distance(ring[1], ring[2])].sort((a, b) => a - b);
assert.ok(Math.abs(lengths[0] - .25) < .01 && Math.abs(lengths[1] - 2) < .01);
assert.ok(feature.properties.segment_id && feature.properties.directional_road_ids && feature.properties.osm_way_ids && feature.properties.placement_rule);
}
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
assert.ok(geometry.connectors.features.length > 0);
@@ -46,10 +54,15 @@ 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")));
assert.ok(controlGeometry.centerLines.features.length > 0);
assert.ok(controlGeometry.centerLines.features.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0]))));
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));
assert.equal(arrowControlGeometry.centerLines.features.length, 0);
const serviceCenterLineOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><way id="64"><nd ref="1"/><nd ref="2"/><tag k="highway" v="service"/></way></osm>`;
assert.equal(compileGeometry(compileRoadModel(serviceCenterLineOsm, empty)).centerLines.features.length, 0);
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));
@@ -112,6 +125,9 @@ try {
const compiledArea = compileArea(config);
assert.equal(compiledArea.result.areaId, "fresh");
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
const centerLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "center_lines.geojson"), "utf8"));
assert.equal(centerLineLayer.type, "FeatureCollection");
assert.equal(centerLineLayer.features.length, compiledArea.comparison.nativeCenterLineFeatures);
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);
@@ -123,4 +139,13 @@ try {
} finally {
fs.rmSync(freshArea, { recursive: true, force: true });
}
function distance(a, b) {
return Math.hypot((b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180), (b[1] - a[1]) * 111320);
}
function 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);
return !(a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]);
}
console.log("native road tests passed");

View File

@@ -27,6 +27,10 @@ assert.match(app, /外缘扩张倍率/);
assert.match(app, /最大外缘扩张/);
assert.match(app, /道路方向箭头/);
assert.match(app, /native-road-direction-arrow\/v1/);
assert.match(app, /data-layer="centerLines" type="checkbox" checked> 道路中心虚线/);
assert.match(app, /centerLines: new VectorLayer/);
assert.match(app, /state\.layers\.centerLines/);
assert.match(app, /native-road-center-line\/v1/);
assert.match(app, /data-layer="controls" type="checkbox" checked> 斑马线与停止线/);
assert.match(app, /controls: new VectorLayer/);
assert.match(app, /state\.layers\.crosswalks/);

View File

@@ -45,9 +45,11 @@ 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> 车道分隔线与路口转向箭头';
const centerLinesToggle = document.createElement("label");
centerLinesToggle.innerHTML = '<input data-layer="centerLines" type="checkbox" checked> 道路中心虚线';
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);
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, controlsToggle);
let state;
let selectedRoad = null;
@@ -66,6 +68,7 @@ const layers = {
lanes: new VectorLayer({ source: source(), style: laneStyle }),
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
markings: new VectorLayer({ source: source(), style: markingStyle }),
centerLines: new VectorLayer({ source: source(), style: centerLineStyle }),
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 }),
@@ -73,8 +76,8 @@ const layers = {
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
};
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.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)" }) }) });
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, 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.centerLines, 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];
@@ -87,15 +90,17 @@ select.on("select", ({ selected }) => {
const road = roadForFeature(feature);
const directionArrow = provenance === "native-road-direction-arrow/v1";
const turnArrow = provenance === "native-road-turn-arrow/v1";
const centerLine = provenance === "native-road-center-line/v1";
const crosswalk = provenance === "native-road-crosswalk/v1";
const stopLine = provenance === "native-road-stop-line/v1";
const markingType = crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
const markingType = centerLine ? "道路中心虚线" : 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"),
道路段: centerLine ? feature.get("segment_id") : null,
方向: 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,
@@ -125,6 +130,7 @@ function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); }
function markingStyle() { return new Style({ fill: new Fill({ color: "#f5f6ee" }), stroke: new Stroke({ color: "#d9dacf", width: 1 }) }); }
function centerLineStyle() { return new Style({ fill: new Fill({ color: "#f5be2a" }), stroke: new Stroke({ color: "#d29d16", width: .8 }) }); }
function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines
// are editing aids, not physical seams, so scene mode must render fills only.
@@ -159,6 +165,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.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines));
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 })) }));
@@ -219,6 +226,7 @@ function renderSummary() {
["普通构面路口", comparison.nativeApproachEnvelopeJunctions],
["兜底构面路口", comparison.nativeFallbackJunctions],
["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio],
["道路中心虚线", comparison.nativeCenterLineFeatures],
["道路方向箭头", comparison.nativeDirectionArrowFeatures],
["路口转向箭头", comparison.nativeTurnArrowFeatures],
["斑马线条带", comparison.nativeCrosswalkFeatures],
@@ -250,6 +258,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.centerLines.setVisible(document.querySelector('[data-layer="centerLines"]').checked);
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
layers.diagnostics.setVisible(!scenePreview);
layers.native.changed(); layers.sidewalks.changed();