diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md
index 66a2664..ad6e6c5 100644
--- a/.trellis/spec/pipeline/cli-and-stages.md
+++ b/.trellis/spec/pipeline/cli-and-stages.md
@@ -414,6 +414,83 @@ GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
`turn_arrows.geojson`。工作台用两个开关呈现,Blender 复用同一现有箭头材质。
+## Native 道路中心虚线
+
+### 1. 范围与触发条件
+
+`node scripts/compile-native-roads.js --config ` 为可确认的双向非 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` 返回该 FeatureCollection;native
+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. 范围与触发条件
diff --git a/.trellis/tasks/08-13-native-road-compiler/task.json b/.trellis/tasks/08-13-native-road-compiler/task.json
index 428eb6f..16eed84 100644
--- a/.trellis/tasks/08-13-native-road-compiler/task.json
+++ b/.trellis/tasks/08-13-native-road-compiler/task.json
@@ -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": [],
diff --git a/.trellis/tasks/08-17-native-road-center-lines/check.jsonl b/.trellis/tasks/08-17-native-road-center-lines/check.jsonl
new file mode 100644
index 0000000..9dd3234
--- /dev/null
+++ b/.trellis/tasks/08-17-native-road-center-lines/check.jsonl
@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
diff --git a/.trellis/tasks/08-17-native-road-center-lines/design.md b/.trellis/tasks/08-17-native-road-center-lines/design.md
new file mode 100644
index 0000000..da58741
--- /dev/null
+++ b/.trellis/tasks/08-17-native-road-center-lines/design.md
@@ -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.
diff --git a/.trellis/tasks/08-17-native-road-center-lines/implement.jsonl b/.trellis/tasks/08-17-native-road-center-lines/implement.jsonl
new file mode 100644
index 0000000..9dd3234
--- /dev/null
+++ b/.trellis/tasks/08-17-native-road-center-lines/implement.jsonl
@@ -0,0 +1 @@
+{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
diff --git a/.trellis/tasks/08-17-native-road-center-lines/implement.md b/.trellis/tasks/08-17-native-road-center-lines/implement.md
new file mode 100644
index 0000000..fd8b71b
--- /dev/null
+++ b/.trellis/tasks/08-17-native-road-center-lines/implement.md
@@ -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.
diff --git a/.trellis/tasks/08-17-native-road-center-lines/prd.md b/.trellis/tasks/08-17-native-road-center-lines/prd.md
new file mode 100644
index 0000000..1a0497f
--- /dev/null
+++ b/.trellis/tasks/08-17-native-road-center-lines/prd.md
@@ -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.
diff --git a/.trellis/tasks/08-17-native-road-center-lines/task.json b/.trellis/tasks/08-17-native-road-center-lines/task.json
new file mode 100644
index 0000000..ce70014
--- /dev/null
+++ b/.trellis/tasks/08-17-native-road-center-lines/task.json
@@ -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": {}
+}
\ No newline at end of file
diff --git a/blender/generate_scene.py b/blender/generate_scene.py
index 8ece1a4..af56985 100644
--- a/blender/generate_scene.py
+++ b/blender/generate_scene.py
@@ -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):
diff --git a/blender/osmassets/catalog.py b/blender/osmassets/catalog.py
index 99cc9b8..92aea28 100644
--- a/blender/osmassets/catalog.py
+++ b/blender/osmassets/catalog.py
@@ -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"},
diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py
index 93c4cc3..500bb3d 100644
--- a/blender/tests/test_pure.py
+++ b/blender/tests/test_pure.py
@@ -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))
diff --git a/scripts/build-area.js b/scripts/build-area.js
index 23fd425..59008ca 100755
--- a/scripts/build-area.js
+++ b/scripts/build-area.js
@@ -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")),
diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js
index 7a5e4b8..811d2d9 100644
--- a/scripts/compile-native-roads.js
+++ b/scripts/compile-native-roads.js
@@ -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,
diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js
index 2b6c96f..fa8dbfb 100644
--- a/scripts/lib/native-road.js
+++ b/scripts/lib/native-road.js
@@ -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) {
diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js
index 2431b21..e1a06ab 100644
--- a/scripts/road-workbench.js
+++ b/scripts/road-workbench.js
@@ -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")); }
diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js
index 95b3aa0..e94ab5d 100644
--- a/scripts/test-native-road.js
+++ b/scripts/test-native-road.js
@@ -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 = ``;
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 = ``;
+assert.equal(compileGeometry(compileRoadModel(serviceCenterLineOsm, empty)).centerLines.features.length, 0);
const crossOsm = ``;
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");
diff --git a/scripts/test-road-workbench.js b/scripts/test-road-workbench.js
index 9bd3967..5d2117e 100644
--- a/scripts/test-road-workbench.js
+++ b/scripts/test-road-workbench.js
@@ -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/);
diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js
index fca6563..49ee8b3 100644
--- a/scripts/workbench/app.js
+++ b/scripts/workbench/app.js
@@ -45,9 +45,11 @@ const directionArrowsToggle = document.createElement("label");
directionArrowsToggle.innerHTML = ' 道路方向箭头';
const markingsToggle = document.createElement("label");
markingsToggle.innerHTML = ' 车道分隔线与路口转向箭头';
+const centerLinesToggle = document.createElement("label");
+centerLinesToggle.innerHTML = ' 道路中心虚线';
const controlsToggle = document.createElement("label");
controlsToggle.innerHTML = ' 斑马线与停止线';
-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();