From fb863dafb2811b7a42aae7cf85edd8c9d6f9670b Mon Sep 17 00:00:00 2001 From: que01 Date: Mon, 17 Aug 2026 10:44:01 +0800 Subject: [PATCH] feat: add native road center lines --- .trellis/spec/pipeline/cli-and-stages.md | 77 +++++++++++++++++++ .../08-13-native-road-compiler/task.json | 3 +- .../check.jsonl | 1 + .../08-17-native-road-center-lines/design.md | 50 ++++++++++++ .../implement.jsonl | 1 + .../implement.md | 19 +++++ .../08-17-native-road-center-lines/prd.md | 58 ++++++++++++++ .../08-17-native-road-center-lines/task.json | 26 +++++++ blender/generate_scene.py | 4 +- blender/osmassets/catalog.py | 1 + blender/tests/test_pure.py | 8 +- scripts/build-area.js | 4 +- scripts/compile-native-roads.js | 4 +- scripts/lib/native-road.js | 32 +++++++- scripts/road-workbench.js | 2 +- scripts/test-native-road.js | 25 ++++++ scripts/test-road-workbench.js | 4 + scripts/workbench/app.js | 17 +++- 18 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 .trellis/tasks/08-17-native-road-center-lines/check.jsonl create mode 100644 .trellis/tasks/08-17-native-road-center-lines/design.md create mode 100644 .trellis/tasks/08-17-native-road-center-lines/implement.jsonl create mode 100644 .trellis/tasks/08-17-native-road-center-lines/implement.md create mode 100644 .trellis/tasks/08-17-native-road-center-lines/prd.md create mode 100644 .trellis/tasks/08-17-native-road-center-lines/task.json 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();