Compare commits
10 Commits
1da985c932
...
ea8a3622b9
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8a3622b9 | |||
| 03a8d6159b | |||
| 1b9829d9ed | |||
| 822e6ef936 | |||
| 3eea12c6ea | |||
| 3d57655497 | |||
| 65cf8b96d9 | |||
| e1f3fc10ca | |||
| f43a122ff7 | |||
| 3e22a1872e |
@@ -132,6 +132,7 @@ cp config/examples/template.json config/areas/my-area.json
|
||||
|---|---|---|
|
||||
| `treeStyle` | `"natural"` | 合法值见 `generate_scene.py` 的 `TREE_STYLES`(`natural`、`procedural`、`shapespark`) |
|
||||
| `officeOverrides` | `""` | 旧名 `office_overrides` 仍被接受 |
|
||||
| `roadProvider` | `"osm2streets"` | Blender 道路来源。`"native"` 时仅使用 `native-road/` 的道路、路口和人行道面;可由 `build:area --road-provider native` 临时覆盖。 |
|
||||
|
||||
### `compress`
|
||||
|
||||
|
||||
@@ -367,6 +367,53 @@ if (!networkSaysIntersection && (roadCounts.get(endpoint.id) || 0) < 3) return n
|
||||
fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
|
||||
```
|
||||
|
||||
## Native 道路标线
|
||||
|
||||
### 1. 范围与触发条件
|
||||
|
||||
`node scripts/compile-native-roads.js --config <area>` 将原生道路标线写入
|
||||
`outputs/<area>/native-road/layers/`。它不读取 osm2streets 渲染几何;只复用
|
||||
`turn-lane-arrows.js` 中已测试的箭头模板。工作台 `GET /api/state` 原样服务这些
|
||||
GeoJSON,native Blender 构建通过 `catalog.NATIVE_ROAD_LAYERS` 消费它们。
|
||||
|
||||
### 2. 图层契约
|
||||
|
||||
| 文件 | 语义 | 必需 provenance | Blender material layer |
|
||||
|---|---|---|---|
|
||||
| `lane_separators.geojson` | 同向相邻车道的分隔线 | `native-road-lane-separator/v1` | `lane_separators` |
|
||||
| `direction_arrows.geojson` | 沿定向车道重复的直行方向箭头 | `native-road-direction-arrow/v1` | `lane_arrows_webscale` |
|
||||
| `turn_arrows.geojson` | 明确 `turn:lanes` 的路口动作箭头 | `native-road-turn-arrow/v1` | `lane_arrows_webscale` |
|
||||
|
||||
方向箭头必须带 `road_id`、`lane_id`、`osm_way_ids`、`direction`、`lane_index`、
|
||||
`sequence`、`distance_along_lane_meters` 和 `placement_interval_meters`。转向箭头
|
||||
必须带 `maneuver` 与 `placement_distance_meters`。两者不能共用 provenance 或假装为
|
||||
彼此:前者表达沿路行驶方向,后者表达路口处允许动作。
|
||||
|
||||
### 3. 放置与错误矩阵
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 车道长度不足以容纳两端 14m 缓冲 | 不生成道路方向箭头 |
|
||||
| 可用车道长度 | 从 14m 起按 32m 间距生成 `through` 箭头 |
|
||||
| OSM 未提供 `turn:lanes` | 不生成路口转向箭头 |
|
||||
| `turn:lanes` 存在但动作不受已测试模板支持 | 记录诊断,不猜测动作 |
|
||||
| native Blender 构建缺任一图层文件 | 在 `ensureNativeRoadLayers()` 失败,不能静默漏画 |
|
||||
|
||||
### 4. 必需测试
|
||||
|
||||
- `npm run test:native-road`:方向箭头的 Polygon、provenance、间距,以及无标签道路
|
||||
不生成路口转向箭头。
|
||||
- `npm run test:road-workbench`:方向箭头开关、选择溯源和概览标签。
|
||||
- `npm run build:area -- --config config/areas/nantaizi-lake-innovation-valley.json --stages blender,cesium,preview --road-provider native`:日志必须列出
|
||||
`direction_arrows` 与 `turn_arrows`,且不运行 `package`。
|
||||
|
||||
### 5. 错误与正确写法
|
||||
|
||||
错误:把默认 `through` 当作路口 `turn:lanes` 动作,统一写入 `turn_arrows.geojson`。
|
||||
|
||||
正确:道路方向箭头进入 `direction_arrows.geojson`;只有 OSM 明确标注的动作进入
|
||||
`turn_arrows.geojson`。工作台用两个开关呈现,Blender 复用同一现有箭头材质。
|
||||
|
||||
## 斑马线与停止线来源
|
||||
|
||||
### 1. 范围与触发条件
|
||||
|
||||
@@ -120,6 +120,56 @@ SCENE_LAYERS.forEach((layer, index) => {
|
||||
|
||||
## Blender 调用
|
||||
|
||||
### macOS Blender 4.5 的 Metal 启动兼容
|
||||
|
||||
#### 1. Scope / Trigger
|
||||
|
||||
`export_cesium.py` 在 macOS 的 Blender 4.5.12 后台启动时,可能在 Python 脚本加载前的 Metal 扩展探测中崩溃;这不是场景或道路数据错误。
|
||||
|
||||
#### 2. Signatures
|
||||
|
||||
Cesium 阶段的调用参数必须包含:
|
||||
|
||||
```text
|
||||
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py -- ...
|
||||
```
|
||||
|
||||
#### 3. Contracts
|
||||
|
||||
`--debug-gpu-force-workarounds` 是 Blender 的官方 CLI 参数。它只约束导出进程的 GPU 扩展探测,不改变 `.blend`、GeoJSON 或导出脚本的输入输出契约。
|
||||
|
||||
#### 4. Validation & Error Matrix
|
||||
|
||||
| 情况 | 结果 |
|
||||
|---|---|
|
||||
| 缺少该参数且启动时崩在 Metal 初始化 | 不应归因于道路数据;补齐参数后重跑 Cesium 阶段 |
|
||||
| 参数存在且 `CESIUM_EXPORT_DONE` / stage manifest 写出 | 继续 GLB digest 与预览验证 |
|
||||
|
||||
#### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good: 保留 `--factory-startup`,并在 Cesium 导出加入 workaround。
|
||||
- Base: Blender 场景阶段未受影响时,不额外改变其启动参数。
|
||||
- Bad: 为绕过启动崩溃删除 `--factory-startup`,这会重新引入本机偏好和 addon 的不确定性。
|
||||
|
||||
#### 6. Tests Required
|
||||
|
||||
- `npm run test:build-stages` 断言导出参数仍包含 workaround。
|
||||
- 对目标区域运行 `--stages blender,cesium,preview`,并用 `glb-digest.js` 解析输出。
|
||||
|
||||
#### 7. Wrong vs Correct
|
||||
|
||||
Wrong:
|
||||
|
||||
```text
|
||||
--background --python blender/export_cesium.py
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```text
|
||||
--background --factory-startup --debug-gpu-force-workarounds --python blender/export_cesium.py
|
||||
```
|
||||
|
||||
### 两种调用姿势
|
||||
|
||||
| 阶段 | 参数 | 出处 |
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"children": [
|
||||
"08-14-native-road-lane-markings"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Native Lane Markings Design
|
||||
|
||||
## Architecture
|
||||
|
||||
The native compiler remains the source of truth. It derives two new polygon
|
||||
layers alongside its existing surface, sidewalk, lane-centerline, and
|
||||
connector outputs:
|
||||
|
||||
```text
|
||||
canonical directed roads + lane centerlines + junction cutbacks
|
||||
|
|
||||
+-- lane separators: paint polygons between adjacent same-direction lanes
|
||||
|
|
||||
+-- direction arrows: repeated through-arrow template on directed lanes,
|
||||
| outside the reserved junction marking zone
|
||||
|
|
||||
+-- turn arrows: tested existing template, anchored to an incoming lane
|
||||
only when that lane has an explicit supported turn:lanes value
|
||||
```
|
||||
|
||||
No OSM2streets rendered geometry is consumed by this path. The existing arrow
|
||||
template library is reused only as a geometry/style asset, so the native lane
|
||||
ID and OSM tags remain the evidence for placement.
|
||||
|
||||
## Contracts
|
||||
|
||||
- `layers/lane_separators.geojson`: polygon FeatureCollection. Each feature
|
||||
records `native_id`, directed `road_id`, adjacent lane indices, source OSM
|
||||
ways, and `native-road-lane-separator/v1` provenance.
|
||||
- `layers/turn_arrows.geojson`: polygon FeatureCollection. Each feature records
|
||||
its `native_id`, `road_id`, `lane_id`, OSM way IDs, direction, lane index,
|
||||
maneuver, template asset, placement distance, and placement provenance.
|
||||
- Unsupported turn values, a missing usable incoming-lane segment, or an
|
||||
insufficient pre-junction placement distance create a diagnostic and no
|
||||
arrow geometry.
|
||||
- `layers/direction_arrows.geojson`: polygon FeatureCollection. Each feature
|
||||
records the native lane and directed road, OSM way IDs, a stable sequence
|
||||
index, its distance along the lane, and `native-road-direction-arrow/v1`
|
||||
provenance. It uses the tested `through` template but is not a turn claim.
|
||||
- The workbench serves both layers, draws them separately from its current
|
||||
centerline/connector debug layer, and selects them by `native_id`.
|
||||
- `catalog.NATIVE_ROAD_LAYERS` maps the two native sources to the existing
|
||||
`lane_separators` and `lane_arrows_webscale` Blender materials. The native
|
||||
adapter does not extend the osm2streets scene-layer registry.
|
||||
|
||||
## Placement
|
||||
|
||||
An incoming lane is oriented in driving direction. A turn arrow is sampled
|
||||
from that lane's endpoint backwards by the configured safe distance, staying
|
||||
outside the junction cutback. Its template basis uses the sampled lane tangent;
|
||||
therefore it is on and aligned with the lane rather than the OSM centerline or
|
||||
a screen-space direction. Multiple template rings remain separate polygons.
|
||||
|
||||
Lane separators are narrow polygons centered between adjacent lane centerlines
|
||||
on a single directional carriageway. They stop at the same junction cutbacks as
|
||||
the lane centerlines. A one-lane direction produces none.
|
||||
|
||||
Direction arrows are sampled at a fixed road-scale interval along the same
|
||||
directed lane centerline. Their candidates exclude both endpoint buffers and
|
||||
the turn-arrow reserve at the incoming end. This preserves a readable repeated
|
||||
direction cue without overlapping a turn instruction at a junction.
|
||||
|
||||
## Compatibility And Rollback
|
||||
|
||||
All new files are additive under `native-road/layers/`. The existing
|
||||
osm2streets/QGIS layer contract and `package/` are unchanged. Selecting
|
||||
`--road-provider osm2streets` remains rollback. Missing native marking files
|
||||
are a native Blender build error rather than a silent omission.
|
||||
|
||||
## Risks
|
||||
|
||||
- OSM turn tags can be incomplete or incompatible with the inferred lane
|
||||
count. These are diagnostics, not guessed arrows.
|
||||
- Very short approaches can have no safe position before the cutback. They are
|
||||
skipped with a source-traceable diagnostic.
|
||||
- Blender and Cesium need a real native build to verify the mesh/material
|
||||
contract, not only GeoJSON unit tests.
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Implementation Plan
|
||||
|
||||
1. Add reusable template-placement helper to the existing turn-arrow module,
|
||||
preserving its supported-asset gate and output ring shape.
|
||||
2. Extend the native compiler with lane-separator, repeated road-direction
|
||||
arrow, and explicit-turn-arrow polygon generation plus source diagnostics
|
||||
and layer persistence.
|
||||
3. Extend native output records/counts and Blender adapter mappings. Reuse
|
||||
existing `lane_separators` and `lane_arrows_webscale` materials only.
|
||||
4. Add Workbench layers, toggles, selection/provenance inspector entries, and
|
||||
preserve the existing Workbench-only direction triangle behavior.
|
||||
5. Add focused fixtures for supported turn placement, unsupported maneuver,
|
||||
short approach skip, and separator geometry; run native/workbench/build
|
||||
stage tests.
|
||||
6. Build Nantaizi with `blender,cesium,preview --road-provider native`, inspect
|
||||
the final preview, and confirm no `package/` publication occurred.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npm run test:native-road
|
||||
npm run test:road-workbench
|
||||
npm run test:turn-lane-arrows
|
||||
npm run test:build-stages
|
||||
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
|
||||
```
|
||||
|
||||
Rollback is selecting `--road-provider osm2streets`; no existing output path
|
||||
is replaced.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Native road lane markings and turn arrows
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the native-road visual language for Nantaizi before any cross-area
|
||||
migration: lane separators, travel-direction markers, and OSM-backed turn
|
||||
arrows must be inspectable in the Road Workbench and visible in Blender and
|
||||
Cesium output.
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- Native output currently contains directed lane centerlines and connector
|
||||
curves, but it does not emit paintable lane-separator or turn-arrow polygons.
|
||||
- The existing osm2streets path has tested arrow templates in
|
||||
`scripts/lib/turn-lane-arrows.js`, the `lane_arrows_webscale` material layer,
|
||||
and a matching Blender material. Reuse these instead of introducing a second
|
||||
arrow style.
|
||||
- Earlier reviews established that direction markers must sit on the OSM / lane
|
||||
centerline, use a clearly directional sharp triangle, and never be treated as
|
||||
a road-surface decoration that drifts sideways.
|
||||
- Turn arrows must follow the actual incoming lane and be placed before its
|
||||
junction, with OSM source way, direction, lane index, and maneuver retained
|
||||
as provenance.
|
||||
- Scope remains Nantaizi only. Existing osm2streets output remains untouched.
|
||||
|
||||
## Requirements
|
||||
|
||||
- R1: Native compilation emits polygonal lane-separator markings derived from
|
||||
its own directed lane geometry.
|
||||
- R2: Native compilation emits turn-arrow polygons for supported OSM
|
||||
`turn:lanes` maneuvers, using the existing tested arrow templates and the
|
||||
exact native incoming-lane centerline for placement.
|
||||
- R2a: Native compilation emits repeated straight-ahead direction-arrow
|
||||
polygons along directed lanes, matching the visual role of osm2streets'
|
||||
ordinary road arrows. These are a separate layer from turn arrows, retain
|
||||
their own provenance, and leave a clear buffer around junction turn arrows.
|
||||
- R3: Native output preserves provenance for every marking: native road/lane
|
||||
ID, OSM way IDs, direction, lane number, maneuver, and placement method.
|
||||
- R4: The Workbench renders markings in a separately controllable layer and
|
||||
exposes those provenance fields on selection.
|
||||
- R5: The native Blender adapter consumes native marking layers through the
|
||||
existing lane-separator and lane-arrow material layers; Cesium must receive
|
||||
the same geometry through the exported GLB.
|
||||
- R6: Unsupported, unplaceable, or ambiguous arrow inputs become diagnostics;
|
||||
the compiler must not invent a maneuver.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Nantaizi native output contains valid polygon GeoJSON for generated lane
|
||||
separators, repeated road direction arrows, and every supported, explicitly
|
||||
tagged turn arrow.
|
||||
- [ ] A Workbench user can toggle, select, and inspect a generated marker and
|
||||
see its lane, OSM, maneuver, and placement provenance.
|
||||
- [ ] A selected direction marker is geometrically aligned to its directed lane
|
||||
centerline; a selected turn arrow is on its incoming lane before the junction.
|
||||
- [ ] Blender scene output and Cesium GLB contain native lane markings and
|
||||
arrows with the existing visual material language.
|
||||
- [ ] Unit tests cover a normal supported arrow, an unsupported maneuver, and
|
||||
an unsafe/too-short placement; native compile and final Nantaizi visual build
|
||||
pass without publishing `package/`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Inventing turn arrows for untagged lanes, traffic-control semantics, changing
|
||||
QGIS/osm2streets layers, or processing another area.
|
||||
|
||||
## Key Decision
|
||||
|
||||
- Sharp travel-direction triangles remain a Workbench-only inspection aid.
|
||||
They explain raw OSM node order after a road is selected.
|
||||
- Repeated `through` direction arrows are final road markings, distinct from
|
||||
both those debug triangles and OSM-backed junction turn arrows. They are
|
||||
placed on native lane centerlines at a fixed interval and enter the same
|
||||
Blender/Cesium material layer as turn arrows.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "native-road-lane-markings",
|
||||
"name": "native-road-lane-markings",
|
||||
"title": "Native road lane markings and turn arrows",
|
||||
"description": "Complete Nantaizi native lane separators, travel direction and turn-arrow geometry through Road Workbench, Blender and Cesium before cross-area migration.",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-14",
|
||||
"completedAt": "2026-08-14",
|
||||
"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": {}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 30
|
||||
- **Last Active**: 2026-08-12
|
||||
- **Total Sessions**: 31
|
||||
- **Last Active**: 2026-08-14
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~642 | Active |
|
||||
| `journal-1.md` | ~663 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 31 | 2026-08-14 | Native road lane markings | `1b9829d` | `feature/native-road-compiler` |
|
||||
| 30 | 2026-08-12 | Add vehicle incident preview cards | `761a526` | `main` |
|
||||
| 29 | 2026-08-12 | Publish reusable area asset packages | `f385009`, `c925890`, `db0fba5`, `f2b8d79`, `0102ffb` | `main` |
|
||||
| 28 | 2026-08-11 | 默认压缩交付 | `0790cbd` | `main` |
|
||||
|
||||
@@ -640,3 +640,24 @@ Added click-to-open vehicle information cards with normal, breakdown, and accide
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 31: Native road lane markings
|
||||
|
||||
**Date**: 2026-08-14
|
||||
**Task**: Native road lane markings
|
||||
**Branch**: `feature/native-road-compiler`
|
||||
|
||||
### Summary
|
||||
|
||||
Implemented native lane separators, repeated road direction arrows, explicit junction turn arrows, Workbench inspection/toggles, and Blender/Cesium adapter support for Nantaizi; validated native compile, checks, tests, and non-package visual build.
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `1b9829d` | (see git log) |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
@@ -657,6 +657,8 @@ def export(args):
|
||||
raise RuntimeError("Dynamic traffic signal collection is empty")
|
||||
export_glb(args["dynamic_glb"], dynamic_meshes)
|
||||
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
|
||||
if not args.get(key):
|
||||
continue
|
||||
if not countdown_meshes[group]:
|
||||
raise RuntimeError("Traffic countdown collection %d is empty" % group)
|
||||
export_glb(args[key], countdown_meshes[group])
|
||||
|
||||
@@ -110,7 +110,7 @@ TREE_STYLES = frozenset(("natural", "procedural")) | frozenset(_tree.MODEL_STYLE
|
||||
|
||||
|
||||
def cli_args():
|
||||
values = {"osm": None, "geojson": None, "output": None, "render": None,
|
||||
values = {"osm": None, "geojson": None, "native_road": None, "output": None, "render": None,
|
||||
"office_overrides": "", "tree_style": "natural"}
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
i = 0
|
||||
@@ -789,8 +789,23 @@ def build(args):
|
||||
_features.dispatch_ways(ways, projector, way_handlers)
|
||||
|
||||
geojson_dir = args.get("geojson")
|
||||
native_road_dir = args.get("native_road")
|
||||
road_counts = {}
|
||||
if geojson_dir and os.path.isdir(geojson_dir):
|
||||
if native_road_dir:
|
||||
if not os.path.isdir(native_road_dir):
|
||||
raise RuntimeError("--native-road directory does not exist: " + native_road_dir)
|
||||
material_layers = {layer["id"]: layer for layer in catalog.ROAD_LAYERS}
|
||||
for source in catalog.NATIVE_ROAD_LAYERS:
|
||||
target = source["material_layer"]
|
||||
layer = material_layers[target]
|
||||
source_path = os.path.join(native_road_dir, "layers", source["source"] + ".geojson")
|
||||
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):
|
||||
print("Layer catalog warning:", problem)
|
||||
for layer in catalog.ROAD_LAYERS:
|
||||
@@ -799,7 +814,7 @@ def build(args):
|
||||
os.path.join(geojson_dir, layer_id + ".geojson"), layer_id,
|
||||
projector, roads_c, road_mats[layer_id], layer["z"])
|
||||
|
||||
if road_counts.get("road_surface", 0) == 0:
|
||||
if not native_road_dir and road_counts.get("road_surface", 0) == 0:
|
||||
_roads.assemble_osm_fallback(
|
||||
ways, projector, roads_c, road_mats["road_surface"])
|
||||
|
||||
@@ -910,6 +925,7 @@ def build(args):
|
||||
scene.render.filepath = args["render"]
|
||||
scene["source_osm"] = args["osm"]
|
||||
scene["source_geojson"] = geojson_dir or ""
|
||||
scene["source_native_road"] = native_road_dir or ""
|
||||
scene["osm_bounds"] = json.dumps(bounds, ensure_ascii=True)
|
||||
scene["building_count"] = counts["building_count"]
|
||||
scene["industrial_building_count"] = counts["industrial_count"]
|
||||
|
||||
@@ -48,6 +48,17 @@ ROAD_LAYERS = [
|
||||
|
||||
SCENE_STYLE_FILE = "osm2streets_scene_style.json"
|
||||
|
||||
# Native-road output intentionally maps into existing scene material layers.
|
||||
# It is a provider adapter, not a second scene-layer registry.
|
||||
NATIVE_ROAD_LAYERS = (
|
||||
{"source": "road_surface", "material_layer": "road_surface"},
|
||||
{"source": "intersection_surface", "material_layer": "intersection_surface"},
|
||||
{"source": "sidewalk_surface", "material_layer": "sidewalks"},
|
||||
{"source": "lane_separators", "material_layer": "lane_separators"},
|
||||
{"source": "direction_arrows", "material_layer": "lane_arrows_webscale"},
|
||||
{"source": "turn_arrows", "material_layer": "lane_arrows_webscale"},
|
||||
)
|
||||
|
||||
|
||||
# Material specs. `kind` selects the builder:
|
||||
# solid — flat base colour
|
||||
|
||||
@@ -30,7 +30,7 @@ from osmassets.geom import (
|
||||
sample_tree_row,
|
||||
signed_polygon_area,
|
||||
)
|
||||
from osmassets.catalog import ROAD_LAYERS
|
||||
from osmassets.catalog import NATIVE_ROAD_LAYERS, ROAD_LAYERS
|
||||
from osmassets.osm import Projector, parse_height, parse_osm, tags
|
||||
|
||||
|
||||
@@ -46,6 +46,24 @@ class RoadLayerCatalogTest(unittest.TestCase):
|
||||
self.assertNotEqual(layers["road_surface"]["z"],
|
||||
layers["intersection_surface"]["z"])
|
||||
|
||||
def test_native_provider_maps_to_existing_material_layers(self):
|
||||
layers = {layer["id"] for layer in ROAD_LAYERS}
|
||||
self.assertEqual(
|
||||
[(entry["source"], entry["material_layer"])
|
||||
for entry in NATIVE_ROAD_LAYERS],
|
||||
[("road_surface", "road_surface"),
|
||||
("intersection_surface", "intersection_surface"),
|
||||
("sidewalk_surface", "sidewalks")])
|
||||
self.assertTrue(all(entry["material_layer"] in layers
|
||||
for entry in NATIVE_ROAD_LAYERS))
|
||||
|
||||
def test_cesium_export_keeps_signal_assets_optional(self):
|
||||
exporter = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "export_cesium.py")
|
||||
with open(exporter, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
self.assertIn('if not args.get(key):\n continue', source)
|
||||
|
||||
|
||||
class GeometryRingsTest(unittest.TestCase):
|
||||
def test_polygon_keeps_only_the_exterior_ring(self):
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
},
|
||||
"blender": {
|
||||
"treeStyle": "natural",
|
||||
"officeOverrides": ""
|
||||
"officeOverrides": "",
|
||||
"roadProvider": "osm2streets"
|
||||
},
|
||||
"compress": {
|
||||
"textureSize": 768,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"road:compile": "node scripts/compile-native-roads.js",
|
||||
"road:check": "node scripts/check-native-roads.js",
|
||||
"road:workbench": "node scripts/road-workbench.js",
|
||||
"test:road-workbench": "node scripts/test-road-workbench.js",
|
||||
"test:native-road": "node scripts/test-native-road.js",
|
||||
"test:preflight": "node scripts/test-area-preflight.js",
|
||||
"test:build-stages": "node scripts/test-build-stages.js",
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { readAreaConfig } = require("./lib/area-config");
|
||||
const { compileArea: compileNativeRoads } = require("./compile-native-roads");
|
||||
const { resolveStages } = require("./lib/build-stages");
|
||||
const { validateManifest, addIntegrity } = require("./lib/package-contract");
|
||||
const { blenderExecutable: resolveBlenderExecutable } = require("./lib/tool-paths");
|
||||
@@ -42,6 +43,10 @@ const requestedStages = args.stages
|
||||
? splitList(args.stages)
|
||||
: null;
|
||||
const stages = resolveStages(area.stages, requestedStages);
|
||||
const roadProvider = args.roadProvider || area.blender.roadProvider;
|
||||
if (!new Set(["osm2streets", "native"]).has(roadProvider)) {
|
||||
throw new Error("--road-provider must be \"osm2streets\" or \"native\".");
|
||||
}
|
||||
|
||||
|
||||
console.log(`Area: ${area.id}`);
|
||||
@@ -55,10 +60,10 @@ if (stages.reimport) {
|
||||
reimportGpkg(area);
|
||||
}
|
||||
if (stages.blender) {
|
||||
buildBlenderScene(area);
|
||||
buildBlenderScene(area, roadProvider);
|
||||
}
|
||||
if (stages.cesium) {
|
||||
exportCesium(area);
|
||||
exportCesium(area, roadProvider);
|
||||
}
|
||||
if (stages.compress) {
|
||||
compressCesiumGlb(area);
|
||||
@@ -208,16 +213,22 @@ function reimportGpkg(area) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildBlenderScene(area) {
|
||||
function buildBlenderScene(area, roadProvider) {
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(path.join(repoRoot, "blender", "generate_scene.py"), "Blender scene generator");
|
||||
ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies");
|
||||
// Blender consumes the editable assembly layer; OSM only initializes it in
|
||||
// intermediates, so QGIS edits remain authoritative across later stages.
|
||||
writeTrafficSignals(area);
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
if (roadProvider === "osm2streets") {
|
||||
ensureFile(area.outputs.trafficSignalAssemblies, "Editable traffic signal assemblies");
|
||||
// Blender consumes the editable assembly layer; OSM only initializes it in
|
||||
// intermediates, so QGIS edits remain authoritative across later stages.
|
||||
writeTrafficSignals(area);
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
}
|
||||
fs.mkdirSync(path.dirname(area.outputs.blend), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.render), { recursive: true });
|
||||
if (roadProvider === "native") {
|
||||
compileNativeRoads(configPath);
|
||||
ensureNativeRoadLayers(area);
|
||||
}
|
||||
|
||||
const blenderArgs = [
|
||||
"--background",
|
||||
@@ -227,8 +238,6 @@ function buildBlenderScene(area) {
|
||||
"--",
|
||||
"--osm",
|
||||
area.input,
|
||||
"--geojson",
|
||||
area.outputs.geojsonDir,
|
||||
"--output",
|
||||
area.outputs.blend,
|
||||
"--render",
|
||||
@@ -236,6 +245,11 @@ function buildBlenderScene(area) {
|
||||
"--tree-style",
|
||||
area.blender.treeStyle,
|
||||
];
|
||||
if (roadProvider === "native") {
|
||||
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
|
||||
} else {
|
||||
blenderArgs.push("--geojson", area.outputs.geojsonDir);
|
||||
}
|
||||
if (area.blender.officeOverrides) {
|
||||
blenderArgs.push("--office-overrides", area.blender.officeOverrides);
|
||||
}
|
||||
@@ -255,17 +269,20 @@ function buildBlenderScene(area) {
|
||||
inputs: {
|
||||
config: fileRecord(configPath),
|
||||
osm: fileRecord(area.input),
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
...sceneGeojsonRecords(area),
|
||||
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
...(roadProvider === "native" ? nativeRoadRecords(area) : sceneGeojsonRecords(area)),
|
||||
...(roadProvider === "osm2streets" ? {
|
||||
geojsonDir: fileRecord(area.outputs.geojsonDir),
|
||||
trafficSignalAssemblies: fileRecord(area.outputs.trafficSignalAssemblies),
|
||||
trafficSignals: fileRecord(area.outputs.trafficSignals),
|
||||
} : {}),
|
||||
},
|
||||
outputs: {
|
||||
blend: fileRecord(area.outputs.blend),
|
||||
render: fileRecord(area.outputs.render),
|
||||
},
|
||||
summary: {
|
||||
geojson: geojsonFeatureCounts(area),
|
||||
...(roadProvider === "native" ? { nativeRoad: nativeRoadFeatureCounts(area) } : { geojson: geojsonFeatureCounts(area) }),
|
||||
roadProvider,
|
||||
blendBytes: fileRecord(area.outputs.blend).bytes,
|
||||
renderBytes: fileRecord(area.outputs.render).bytes,
|
||||
},
|
||||
@@ -273,23 +290,60 @@ function buildBlenderScene(area) {
|
||||
});
|
||||
}
|
||||
|
||||
function exportCesium(area) {
|
||||
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"]) {
|
||||
ensureFile(path.join(area.outputs.nativeRoadDir, "layers", file), `Native road layer ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function nativeRoadRecords(area) {
|
||||
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
||||
return {
|
||||
nativeRoadCompiled: fileRecord(path.join(area.outputs.nativeRoadDir, "compiled.json")),
|
||||
nativeRoadSurface: fileRecord(path.join(root, "road_surface.geojson")),
|
||||
nativeIntersectionSurface: fileRecord(path.join(root, "intersection_surface.geojson")),
|
||||
nativeSidewalkSurface: fileRecord(path.join(root, "sidewalk_surface.geojson")),
|
||||
nativeLaneSeparators: fileRecord(path.join(root, "lane_separators.geojson")),
|
||||
nativeDirectionArrows: fileRecord(path.join(root, "direction_arrows.geojson")),
|
||||
nativeTurnArrows: fileRecord(path.join(root, "turn_arrows.geojson")),
|
||||
};
|
||||
}
|
||||
|
||||
function nativeRoadFeatureCounts(area) {
|
||||
const root = path.join(area.outputs.nativeRoadDir, "layers");
|
||||
return {
|
||||
roadSurface: featureCount(path.join(root, "road_surface.geojson")),
|
||||
intersectionSurface: featureCount(path.join(root, "intersection_surface.geojson")),
|
||||
sidewalkSurface: featureCount(path.join(root, "sidewalk_surface.geojson")),
|
||||
laneSeparators: featureCount(path.join(root, "lane_separators.geojson")),
|
||||
directionArrows: featureCount(path.join(root, "direction_arrows.geojson")),
|
||||
turnArrows: featureCount(path.join(root, "turn_arrows.geojson")),
|
||||
};
|
||||
}
|
||||
|
||||
function exportCesium(area, roadProvider) {
|
||||
ensureFile(blenderExecutable(area), "Blender executable");
|
||||
ensureFile(area.outputs.blend, "Blend scene");
|
||||
ensureFile(path.join(repoRoot, "blender", "export_cesium.py"), "Cesium exporter");
|
||||
fs.rmSync(area.outputs.packageStagingDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.glb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.metadata), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
|
||||
if (roadProvider === "osm2streets") {
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsDynamicGlb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown0Glb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.trafficSignalsCountdown1Glb), { recursive: true });
|
||||
}
|
||||
|
||||
console.log("Stage: cesium");
|
||||
const started = Date.now();
|
||||
const startedAt = new Date(started).toISOString();
|
||||
runCommand(blenderExecutable(area), [
|
||||
const exporterArgs = [
|
||||
"--background",
|
||||
"--factory-startup",
|
||||
// Blender 4.5 on this macOS host can crash while probing Metal extensions
|
||||
// before the exporter script runs; this is Blender's documented workaround.
|
||||
"--debug-gpu-force-workarounds",
|
||||
"--python",
|
||||
path.join(repoRoot, "blender", "export_cesium.py"),
|
||||
"--",
|
||||
@@ -297,18 +351,22 @@ function exportCesium(area) {
|
||||
area.outputs.blend,
|
||||
"--glb",
|
||||
area.outputs.glb,
|
||||
"--dynamic-glb",
|
||||
area.outputs.trafficSignalsDynamicGlb,
|
||||
"--countdown-0-glb",
|
||||
area.outputs.trafficSignalsCountdown0Glb,
|
||||
"--countdown-1-glb",
|
||||
area.outputs.trafficSignalsCountdown1Glb,
|
||||
"--metadata",
|
||||
area.outputs.metadata,
|
||||
], "cesium");
|
||||
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
||||
];
|
||||
if (roadProvider === "osm2streets") {
|
||||
exporterArgs.splice(-2, 0,
|
||||
"--dynamic-glb", area.outputs.trafficSignalsDynamicGlb,
|
||||
"--countdown-0-glb", area.outputs.trafficSignalsCountdown0Glb,
|
||||
"--countdown-1-glb", area.outputs.trafficSignalsCountdown1Glb,
|
||||
);
|
||||
}
|
||||
runCommand(blenderExecutable(area), exporterArgs, "cesium");
|
||||
if (roadProvider === "osm2streets") {
|
||||
ensureFile(area.outputs.trafficSignalsDynamicGlb, "Dynamic traffic signal GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown0Glb, "Traffic countdown group 0 GLB");
|
||||
ensureFile(area.outputs.trafficSignalsCountdown1Glb, "Traffic countdown group 1 GLB");
|
||||
}
|
||||
const semanticAssets = semanticAssetRecords(area);
|
||||
const finished = Date.now();
|
||||
const digest = glbDigest(area.outputs.glb);
|
||||
@@ -325,12 +383,15 @@ function exportCesium(area) {
|
||||
outputs: {
|
||||
glb: fileRecord(area.outputs.glb),
|
||||
metadata: fileRecord(area.outputs.metadata),
|
||||
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
||||
...(roadProvider === "osm2streets" ? {
|
||||
trafficSignalsDynamicGlb: fileRecord(area.outputs.trafficSignalsDynamicGlb),
|
||||
} : {}),
|
||||
semanticAssets,
|
||||
},
|
||||
summary: {
|
||||
glb: glbSummary(digest),
|
||||
budget: evaluateGlbBudget(digest, area.budget),
|
||||
roadProvider,
|
||||
},
|
||||
warnings: glbBudgetWarnings("Cesium", digest, area.budget),
|
||||
});
|
||||
|
||||
@@ -34,15 +34,19 @@ 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", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", connectors: "layers/connectors.geojson" },
|
||||
layers: { roadSurface: "layers/road_surface.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", connectors: "layers/connectors.geojson" },
|
||||
};
|
||||
const comparison = compareOsm2Streets(area, result.model, compiled);
|
||||
writeJsonAtomic(path.join(staging, "compiled.json"), result);
|
||||
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
|
||||
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
|
||||
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
|
||||
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);
|
||||
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", "direction_arrows.geojson"), compiled.directionArrows);
|
||||
writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows);
|
||||
writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
|
||||
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
|
||||
fs.renameSync(staging, area.outputs.nativeRoadDir);
|
||||
@@ -67,12 +71,21 @@ function compareOsm2Streets(area, model, compiled) {
|
||||
diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1;
|
||||
}
|
||||
const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end");
|
||||
const junctions = compiled.intersectionSurface.features;
|
||||
const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback");
|
||||
return {
|
||||
schema: "native-road-comparison/v2",
|
||||
nativeRoadCount: model.roads.length,
|
||||
nativeRoadSurfaceFeatures: compiled.roadSurface.features.length,
|
||||
nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length,
|
||||
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
|
||||
nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length,
|
||||
nativeFallbackJunctions: fallbackJunctions.length,
|
||||
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,
|
||||
nativeDirectionArrowFeatures: compiled.directionArrows.features.length,
|
||||
nativeTurnArrowFeatures: compiled.turnArrows.features.length,
|
||||
nativeConnectorFeatures: compiled.connectors.features.length,
|
||||
nativeMovementCount: compiled.movements.length,
|
||||
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
|
||||
|
||||
@@ -117,6 +117,7 @@ function normalizeAreaConfig(raw, options = {}) {
|
||||
blender: {
|
||||
treeStyle: raw.blender?.treeStyle || "natural",
|
||||
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
|
||||
roadProvider: roadProviderOption(raw.blender?.roadProvider),
|
||||
},
|
||||
compress,
|
||||
budget,
|
||||
@@ -172,6 +173,14 @@ function numberOption(value, fallback, label, min, max) {
|
||||
return number;
|
||||
}
|
||||
|
||||
function roadProviderOption(value) {
|
||||
const provider = value ?? "osm2streets";
|
||||
if (provider !== "osm2streets" && provider !== "native") {
|
||||
throw new Error("blender.roadProvider must be \"osm2streets\" or \"native\".");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function integerOption(value, fallback, label) {
|
||||
const number = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isInteger(number) || number < 1) {
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
|
||||
|
||||
const OVERRIDE_SCHEMA = "native-road-overrides/v1";
|
||||
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
|
||||
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
|
||||
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
|
||||
const DIRECTION_ARROW_INTERVAL_METERS = 32;
|
||||
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
|
||||
|
||||
function parseOsmRoads(xml) {
|
||||
const nodes = new Map();
|
||||
@@ -35,12 +39,19 @@ function compileRoadModel(xml, overrides) {
|
||||
const roads = [];
|
||||
const endpoints = [];
|
||||
const byNode = new Map();
|
||||
for (const way of parsed.ways) {
|
||||
const sharedNodeWayIds = new Map();
|
||||
for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) {
|
||||
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
|
||||
sharedNodeWayIds.get(nodeId).add(way.id);
|
||||
}
|
||||
for (const sourceWay of parsed.ways) {
|
||||
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
|
||||
for (const way of segments) {
|
||||
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
|
||||
for (const direction of directions) {
|
||||
const base = roadAttributes(way.tags, direction);
|
||||
const id = `road:way/${way.id}:${direction}`;
|
||||
const road = { id, osmWayIds: [way.id], direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
|
||||
const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`;
|
||||
const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
|
||||
applyRoadOverrides(road, overrides, diagnostics);
|
||||
roads.push(road);
|
||||
for (const side of ["start", "end"]) {
|
||||
@@ -51,6 +62,7 @@ function compileRoadModel(xml, overrides) {
|
||||
byNode.get(nodeId).push(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
|
||||
const extent = roadExtent(roads);
|
||||
@@ -63,6 +75,17 @@ function compileRoadModel(xml, overrides) {
|
||||
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
|
||||
}
|
||||
|
||||
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
|
||||
const splitIndexes = [0];
|
||||
for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
|
||||
splitIndexes.push(way.refs.length - 1);
|
||||
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
|
||||
return splitIndexes.slice(1).map((end, index) => {
|
||||
const start = splitIndexes[index];
|
||||
return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 };
|
||||
});
|
||||
}
|
||||
|
||||
function roadExtent(roads) {
|
||||
const points = roads.flatMap((road) => road.centerline);
|
||||
return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) };
|
||||
@@ -112,7 +135,7 @@ function loadOverrides(file) {
|
||||
function validateOverrides(value, model) {
|
||||
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
|
||||
const ids = new Set();
|
||||
const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
|
||||
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
|
||||
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
|
||||
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
|
||||
for (const item of value.overrides) {
|
||||
@@ -133,7 +156,11 @@ function validateOverrides(value, model) {
|
||||
}
|
||||
|
||||
function applyRoadOverrides(road, overrides, diagnostics) {
|
||||
for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) {
|
||||
const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id));
|
||||
// A legacy whole-way edit remains the baseline; a segment-specific edit can
|
||||
// deliberately refine it after the compiler has introduced split segments.
|
||||
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
|
||||
for (const item of matching) {
|
||||
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
|
||||
road.appliedOverrideIds.push(item.id);
|
||||
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
|
||||
@@ -171,8 +198,9 @@ function resolveConnections(endpoints, byNode, overrides, diagnostics) {
|
||||
|
||||
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
|
||||
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
|
||||
const wayId = (roadId) => roadId.split(":")[1];
|
||||
return wayId(firstRoadId) === wayId(secondRoadId);
|
||||
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
|
||||
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, "");
|
||||
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
|
||||
}
|
||||
function connectionEndpointsCompatible(model, fromId, toId) {
|
||||
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
|
||||
@@ -190,23 +218,172 @@ function nearbyManualCandidates(endpoints, from) {
|
||||
|
||||
function compileGeometry(model, overrides = { overrides: [] }) {
|
||||
const diagnostics = [...model.diagnostics];
|
||||
const junctionPlans = compileJunctionPlans(model);
|
||||
const features = [];
|
||||
const emittedWays = new Set();
|
||||
const emittedSegments = new Set();
|
||||
for (const road of model.roads) {
|
||||
const wayKey = road.osmWayIds.join(",");
|
||||
if (emittedWays.has(wayKey)) continue;
|
||||
emittedWays.add(wayKey);
|
||||
const directions = model.roads.filter((item) => item.osmWayIds.join(",") === wayKey);
|
||||
const segmentKey = road.segmentId;
|
||||
if (emittedSegments.has(segmentKey)) continue;
|
||||
emittedSegments.add(segmentKey);
|
||||
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
|
||||
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
||||
// Road and junction asphalt share one final material. Keep the carriageway
|
||||
// continuous through the semantic junction overlay; cutting it back creates
|
||||
// visible wedges/gaps without improving the rendered result.
|
||||
const ring = roadRing(road.centerline, totalWidth);
|
||||
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
|
||||
features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
|
||||
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
const lanes = compileLaneCenterlines(model, diagnostics);
|
||||
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
|
||||
const markings = compileLaneMarkings(model, lanes, diagnostics, junctionPlans);
|
||||
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
|
||||
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
|
||||
const junctionFeatures = compileJunctionSurfaces(model, lanes, connectorResult.features, connectorResult.movements, diagnostics);
|
||||
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
|
||||
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
|
||||
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
|
||||
return { roadSurface: { type: "FeatureCollection", features }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
|
||||
}
|
||||
|
||||
function compileLaneMarkings(model, lanes, diagnostics, junctionPlans) {
|
||||
const separators = []; const directionArrows = []; const turnArrows = [];
|
||||
for (const road of model.roads) {
|
||||
const roadLanes = lanes.byRoadId.get(road.id) || [];
|
||||
for (let index = 1; index < roadLanes.length; index += 1) {
|
||||
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
|
||||
if (left.length !== right.length) continue;
|
||||
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
|
||||
const ring = roadRing(centerline, 0.12);
|
||||
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), provenance: "native-road-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane));
|
||||
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
|
||||
const maneuvers = turns ? String(turns).split("|") : [];
|
||||
for (let index = 0; index < roadLanes.length; index += 1) {
|
||||
const lane = roadLanes[index]; const explicitManeuver = maneuvers[index];
|
||||
if (!explicitManeuver) continue;
|
||||
const maneuver = normalizeManeuver(explicitManeuver);
|
||||
if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; }
|
||||
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; }
|
||||
if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; }
|
||||
const center = pointAlongLine([...lane.coordinates].reverse(), 6);
|
||||
const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1);
|
||||
const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]);
|
||||
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
|
||||
const rings = axis ? arrowRingsAt(maneuver, center, axis) : [];
|
||||
if (!rings.length) continue;
|
||||
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: 6, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
|
||||
}
|
||||
}
|
||||
return { separators, directionArrows, turnArrows };
|
||||
}
|
||||
|
||||
function directionArrowFeatures(road, lane) {
|
||||
const length = lineLengthMeters(lane.coordinates);
|
||||
const features = [];
|
||||
for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) {
|
||||
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
|
||||
if (!placement) continue;
|
||||
const rings = arrowRingsAt("through", placement.point, placement.axis);
|
||||
for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
|
||||
const features = [];
|
||||
const byWay = new Map();
|
||||
for (const road of model.roads) {
|
||||
const key = road.segmentId;
|
||||
if (!byWay.has(key)) byWay.set(key, []);
|
||||
byWay.get(key).push(road);
|
||||
}
|
||||
for (const [wayKey, directions] of byWay) {
|
||||
const forward = directions.find((road) => road.direction === "forward") || directions[0];
|
||||
const backward = directions.find((road) => road.id !== forward.id);
|
||||
const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0);
|
||||
const sides = [
|
||||
["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
|
||||
["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
|
||||
];
|
||||
for (const [side, enabled] of sides) {
|
||||
if (!enabled) continue;
|
||||
const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
|
||||
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
|
||||
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
|
||||
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
|
||||
features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
}
|
||||
features.push(...compileSidewalkCorners(model, junctionPlans));
|
||||
return features;
|
||||
}
|
||||
|
||||
function compileSidewalkCorners(model, junctionPlans) {
|
||||
const result = [];
|
||||
for (const [nodeId, plan] of junctionPlans) {
|
||||
const candidates = [];
|
||||
for (const approach of plan.approaches) {
|
||||
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
|
||||
const forward = directions.find((road) => road.direction === "forward") || directions[0];
|
||||
if (!forward) continue;
|
||||
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
|
||||
const sideStates = outwardIsForward
|
||||
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
|
||||
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
|
||||
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
|
||||
if (!cutback) continue;
|
||||
const heading = headingAtEndpoint(approach.line);
|
||||
const halfWidth = approach.widthMeters / 2;
|
||||
for (const [side, enabled] of Object.entries(sideStates)) {
|
||||
if (!enabled) continue;
|
||||
// offsetLine's positive normal is driver's left, which is heading -90
|
||||
// in this north-based heading convention.
|
||||
const sideHeading = heading + (side === "left" ? -90 : 90);
|
||||
candidates.push({
|
||||
wayKey: approach.segmentId,
|
||||
sourceWayKey: forward.osmWayIds.join(","),
|
||||
side,
|
||||
normalDegrees: sideHeading,
|
||||
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
|
||||
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
|
||||
});
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
|
||||
for (let index = 0; index < candidates.length; index += 1) {
|
||||
const first = candidates[index];
|
||||
const second = candidates[(index + 1) % candidates.length];
|
||||
if (first.wayKey === second.wayKey) continue;
|
||||
const ring = [first.curb, first.outer, second.outer, second.curb, first.curb];
|
||||
if (hasSelfIntersection(ring)) continue;
|
||||
if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue;
|
||||
result.push({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
|
||||
osm_node_id: nodeId,
|
||||
kind: "corner",
|
||||
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
|
||||
provenance: "native-road-sidewalk-corner/v1",
|
||||
},
|
||||
geometry: { type: "Polygon", coordinates: [ring] },
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function samePhysicalSide(first, second) {
|
||||
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
|
||||
return Math.cos(radians) >= 0.98;
|
||||
}
|
||||
|
||||
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
|
||||
const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]);
|
||||
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
|
||||
const carriageway = roadRing(approach.line, approach.widthMeters);
|
||||
return carriageway && pointInPolygon(center, carriageway);
|
||||
});
|
||||
}
|
||||
|
||||
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
|
||||
@@ -237,13 +414,13 @@ function pointOnSegment(point, a, b) {
|
||||
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
|
||||
}
|
||||
|
||||
function compileLaneCenterlines(model, diagnostics) {
|
||||
function compileLaneCenterlines(model, diagnostics, junctionPlans) {
|
||||
const features = [];
|
||||
const byRoadId = new Map();
|
||||
for (const road of model.roads) {
|
||||
const lanes = [];
|
||||
const laneWidth = road.widthMeters / road.laneCount;
|
||||
const siblings = model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
||||
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
|
||||
const opposite = siblings.find((item) => item.id !== road.id);
|
||||
// OSM centerline is the shared carriageway center. On a two-way road,
|
||||
// offset each directed carriageway to its own side before placing lanes.
|
||||
@@ -252,7 +429,7 @@ function compileLaneCenterlines(model, diagnostics) {
|
||||
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
|
||||
// driver's left so tag positions and generated lane IDs have one meaning.
|
||||
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
|
||||
const coordinates = offsetLine(road.centerline, offset);
|
||||
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
|
||||
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
|
||||
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
|
||||
lanes.push(lane);
|
||||
@@ -352,27 +529,29 @@ function offsetLine(line, offsetMeters) {
|
||||
}
|
||||
|
||||
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
|
||||
|
||||
function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostics) {
|
||||
const byNode = new Map();
|
||||
for (const endpoint of model.endpoints) {
|
||||
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
|
||||
byNode.get(endpoint.nodeId).push(endpoint);
|
||||
function polygonAreaMeters(ring) {
|
||||
if (ring.length < 3) return 0;
|
||||
const origin = ring[0];
|
||||
const points = ring.map((point) => project(point, origin));
|
||||
let twiceArea = 0;
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const next = points[(index + 1) % points.length];
|
||||
twiceArea += points[index][0] * next[1] - next[0] * points[index][1];
|
||||
}
|
||||
return Math.abs(twiceArea) / 2;
|
||||
}
|
||||
|
||||
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) {
|
||||
const result = [];
|
||||
for (const [nodeId, endpoints] of byNode) {
|
||||
const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1]));
|
||||
if (wayIds.size < 3 || wayIds.size > 4) continue;
|
||||
const node = endpoints[0].coordinate;
|
||||
const approaches = junctionApproaches(model, endpoints);
|
||||
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
||||
const boundary = junctionBoundary(approaches, node, cutbackMeters);
|
||||
for (const [nodeId, plan] of junctionPlans) {
|
||||
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
|
||||
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
|
||||
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
|
||||
if (boundary.length < 3 || !junctionMovements.length) {
|
||||
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
|
||||
continue;
|
||||
}
|
||||
const approachAreaMeters = polygonAreaMeters(boundary);
|
||||
let ring = [...boundary, boundary[0]];
|
||||
let boundaryMode = "approach-envelope";
|
||||
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) {
|
||||
@@ -384,24 +563,48 @@ function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostic
|
||||
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
|
||||
continue;
|
||||
}
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-approach-envelope/v2" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
const surfaceAreaMeters = polygonAreaMeters(ring);
|
||||
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
|
||||
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileJunctionPlans(model) {
|
||||
const byNode = new Map();
|
||||
for (const endpoint of model.endpoints) {
|
||||
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
|
||||
byNode.get(endpoint.nodeId).push(endpoint);
|
||||
}
|
||||
const plans = new Map();
|
||||
for (const [nodeId, endpoints] of byNode) {
|
||||
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
|
||||
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
|
||||
const approaches = junctionApproaches(model, endpoints);
|
||||
if (approaches.length !== segmentIds.size) continue;
|
||||
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
||||
const node = endpoints[0].coordinate;
|
||||
const boundary = junctionBoundary(approaches, node, cutbackMeters);
|
||||
if (boundary.length < 3) continue;
|
||||
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary });
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
function junctionApproaches(model, endpoints) {
|
||||
const groups = new Map();
|
||||
for (const endpoint of endpoints) {
|
||||
const road = model.roads.find((item) => item.id === endpoint.roadId);
|
||||
if (!road) continue;
|
||||
const key = road.osmWayIds.join(",");
|
||||
const key = road.segmentId;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push({ endpoint, road });
|
||||
}
|
||||
return [...groups.values()].map((directions) => {
|
||||
const { endpoint, road } = directions[0];
|
||||
return { line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
|
||||
return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,9 +631,46 @@ function pointAlongLine(line, meters) {
|
||||
return line.at(-1);
|
||||
}
|
||||
|
||||
function pointAndAxisAlongLine(line, meters) {
|
||||
let remaining = meters;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const start = line[index - 1]; const end = line[index];
|
||||
const length = distanceMeters(start, end);
|
||||
if (length < 0.01) continue;
|
||||
if (length >= remaining) {
|
||||
const vector = project(end, start);
|
||||
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
|
||||
}
|
||||
remaining -= length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
|
||||
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
|
||||
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
|
||||
if (!startCutback && !endCutback) return line;
|
||||
const total = lineLengthMeters(line);
|
||||
// Short OSM fragments cannot safely lose both ends. Keep their source
|
||||
// geometry intact and let the junction diagnostic surface the ambiguity.
|
||||
if (startCutback + endCutback >= total - 0.5) return line;
|
||||
const result = [];
|
||||
let traversed = 0;
|
||||
const start = pointAlongLine(line, startCutback);
|
||||
const end = pointAlongLine(line, total - endCutback);
|
||||
result.push(start);
|
||||
for (let index = 1; index < line.length - 1; index += 1) {
|
||||
traversed += distanceMeters(line[index - 1], line[index]);
|
||||
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
|
||||
}
|
||||
result.push(end);
|
||||
return result;
|
||||
}
|
||||
|
||||
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
|
||||
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
|
||||
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
|
||||
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
|
||||
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
|
||||
function convexHull(points) {
|
||||
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
@@ -483,6 +723,14 @@ function roadRing(line, width) {
|
||||
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
||||
}
|
||||
|
||||
function sidewalkRing(line, innerOffset, outerOffset, side) {
|
||||
const inner = offsetLine(line, innerOffset * side);
|
||||
const outer = offsetLine(line, outerOffset * side);
|
||||
if (!inner || !outer) return null;
|
||||
const ring = [...inner, ...outer.reverse(), inner[0]];
|
||||
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
||||
}
|
||||
|
||||
function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; }
|
||||
function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; }
|
||||
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; }
|
||||
|
||||
@@ -358,6 +358,18 @@ function addMeters(center, axis, axisDistance, right, rightDistance, meters) {
|
||||
];
|
||||
}
|
||||
|
||||
function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) {
|
||||
const normalized = normalizeManeuver(maneuver);
|
||||
if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return [];
|
||||
const meters = metersForLat(center[1]);
|
||||
const length = Math.hypot(axis[0], axis[1]);
|
||||
if (!Number.isFinite(length) || length < 0.001) return [];
|
||||
const forward = [axis[0] / length, axis[1] / length];
|
||||
const right = [forward[1], -forward[0]];
|
||||
return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) =>
|
||||
addMeters(center, forward, forwardMeters, right, rightMeters, meters)));
|
||||
}
|
||||
|
||||
function templateFor(assetId, manifest = loadManifest()) {
|
||||
const asset = supportedAssets(manifest).get(assetId);
|
||||
if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`);
|
||||
@@ -487,4 +499,4 @@ function strokePolygon(points, width) {
|
||||
return [...left, ...right, left[0]];
|
||||
}
|
||||
|
||||
module.exports = { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };
|
||||
module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };
|
||||
|
||||
@@ -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")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
|
||||
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), 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")); }
|
||||
|
||||
@@ -11,9 +11,12 @@ const { digestGltf } = require("./glb-digest");
|
||||
const { evaluateGlbBudget, BUDGETS, fileRecord, writeStageManifest } = require("./lib/stage-manifest");
|
||||
|
||||
const qgisBuildSource = fs.readFileSync(path.join(__dirname, "build-osm2streets-qgis.js"), "utf8");
|
||||
const areaBuildSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8");
|
||||
assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNull/);
|
||||
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
|
||||
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
|
||||
assert.match(areaBuildSource, /exportCesium\(area, roadProvider\)/);
|
||||
assert.match(areaBuildSource, /if \(roadProvider === "osm2streets"\) \{\n exporterArgs\.splice/);
|
||||
|
||||
const gltf = {
|
||||
nodes: [
|
||||
@@ -51,6 +54,12 @@ assert.equal(
|
||||
path.join(tempDir, "test-area", "osm2streets_web_out", "traffic_signal_assemblies.geojson"),
|
||||
);
|
||||
assert.equal(normalizeAreaConfig({ ...base, budget: { nodes: 800 } }).budget.glbNodes, 800);
|
||||
assert.equal(normalizeAreaConfig(base).blender.roadProvider, "osm2streets");
|
||||
assert.equal(normalizeAreaConfig({ ...base, blender: { roadProvider: "native" } }).blender.roadProvider, "native");
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, blender: { roadProvider: "other" } }),
|
||||
/blender\.roadProvider/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeAreaConfig({ ...base, budget: { nodes: 1200 } }),
|
||||
/budget.reason is required/,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const { resolveStages, canonicalStages } = require("./lib/build-stages");
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["compress", "blender", "preview"])), ["blender", "compress", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({}, ["all"])), ["intermediates", "blender", "cesium", "compress", "package", "preview"]);
|
||||
assert.deepEqual(canonicalStages(resolveStages({ intermediates: true, blender: true, cesium: true, compress: true, package: true })), ["intermediates", "blender", "cesium", "compress", "package"]);
|
||||
assert.throws(() => resolveStages({}, ["intermediates", "reimport"]), /mutually exclusive/);
|
||||
const buildAreaSource = fs.readFileSync(require("path").join(__dirname, "build-area.js"), "utf8");
|
||||
assert.match(buildAreaSource, /"--debug-gpu-force-workarounds"/);
|
||||
console.log("Build stage tests passed.");
|
||||
|
||||
@@ -23,15 +23,44 @@ assert.equal(edited.sidewalkLeft, false);
|
||||
const geometry = compileGeometry(model);
|
||||
assert.equal(geometry.roadSurface.features.length, 2);
|
||||
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
|
||||
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.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);
|
||||
assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13));
|
||||
assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id));
|
||||
assert.ok(geometry.movements.length >= geometry.connectors.features.length);
|
||||
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
|
||||
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-approach-envelope/v2"));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3"));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
|
||||
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
|
||||
const 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));
|
||||
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
|
||||
assert.equal(crossGeometry.turnArrows.features.length, 0);
|
||||
assert.ok(crossGeometry.directionArrows.features.length > 0);
|
||||
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
|
||||
assert.ok(crossGeometry.roadSurface.features.some((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) < 4));
|
||||
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
|
||||
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
|
||||
assert.equal(crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner").length, 4);
|
||||
const sharedInteriorNodeOsm = `<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"/><way id="50"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="51"><nd ref="4"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
|
||||
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
|
||||
assert.equal(sharedInteriorModel.roads.length, 6);
|
||||
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/1:forward"));
|
||||
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/2:forward"));
|
||||
const sharedInteriorGeometry = compileGeometry(sharedInteriorModel);
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
|
||||
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
|
||||
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
|
||||
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "corner" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
|
||||
const connection = initial.connections[0];
|
||||
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
|
||||
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
|
||||
@@ -49,6 +78,12 @@ assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", over
|
||||
const turnOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="30"><nd ref="1"/><nd ref="2"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/><tag k="turn:lanes" v="left|through|right"/></way><way id="31"><nd ref="2"/><nd ref="3"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/></way></osm>`;
|
||||
const turnModel = compileRoadModel(turnOsm, empty);
|
||||
const turnGeometry = compileGeometry(turnModel);
|
||||
assert.equal(turnGeometry.sidewalkSurface.features.length, 0);
|
||||
assert.ok(turnGeometry.directionArrows.features.length > 0);
|
||||
assert.ok(turnGeometry.turnArrows.features.length > 0);
|
||||
assert.ok(turnGeometry.turnArrows.features.every((feature) => feature.properties.provenance === "native-road-turn-arrow/v1"));
|
||||
const sidewalkOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "add-sidewalk", kind: "road", roadId: "road:way/30:forward", sidewalkLeft: true }] }, turnModel);
|
||||
assert.ok(compileGeometry(compileRoadModel(turnOsm, sidewalkOverride)).sidewalkSurface.features.some((feature) => feature.properties.native_id === "sidewalk:way/30:left"));
|
||||
assert.equal(turnGeometry.connectors.features.length, 1);
|
||||
assert.match(turnGeometry.connectors.features[0].properties.from_lane_id, /road:way\/30:forward:1$/);
|
||||
assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/);
|
||||
@@ -69,6 +104,8 @@ try {
|
||||
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
|
||||
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
|
||||
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
|
||||
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
|
||||
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
|
||||
assert.equal(checkArea(config).ok, true);
|
||||
} finally {
|
||||
fs.rmSync(freshArea, { recursive: true, force: true });
|
||||
|
||||
31
scripts/test-road-workbench.js
Normal file
31
scripts/test-road-workbench.js
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, "workbench", "index.html"), "utf8");
|
||||
assert.match(html, /id="width" type="number" min="1" step="0\.01"/);
|
||||
assert.match(html, /data-layer="sidewalks" type="checkbox" checked> 路缘与步行带/);
|
||||
assert.match(html, /id="scene-preview" type="checkbox"/);
|
||||
assert.match(html, /id="selected-junction" hidden/);
|
||||
const app = fs.readFileSync(path.join(__dirname, "workbench", "app.js"), "utf8");
|
||||
assert.match(app, /async function saveStagedChanges\(\)/);
|
||||
assert.match(app, /if \(!await saveStagedChanges\(\)\) return;/);
|
||||
assert.match(app, /function stageRoadOverride\(road, changes\)/);
|
||||
assert.match(app, /road\.segmentId === selectedRoad\.segmentId/);
|
||||
assert.match(app, /sidewalkLeft: rightInput\.checked, sidewalkRight: leftInput\.checked/);
|
||||
assert.match(app, /function nativeSurfaceStyle\(feature\)/);
|
||||
assert.match(app, /reference: new VectorLayer\(\{ source: source\(\), visible: false/);
|
||||
assert.match(app, /scene mode must render fills only/);
|
||||
assert.match(app, /scenePreviewToggle\.onchange/);
|
||||
assert.match(app, /layers\.sidewalks\.setVisible\(document\.querySelector\('\[data-layer="sidewalks"\]'\)\.checked\)/);
|
||||
assert.match(app, /function selectJunction\(feature\)/);
|
||||
assert.match(app, /candidate\.get\("native_id"\) === item\.subjectId/);
|
||||
assert.match(app, /外缘扩张倍率/);
|
||||
assert.match(app, /最大外缘扩张/);
|
||||
assert.match(app, /道路方向箭头/);
|
||||
assert.match(app, /native-road-direction-arrow\/v1/);
|
||||
assert.match(app, /term\.textContent = label; detail\.textContent = value; summary\.append\(term, detail\)/);
|
||||
console.log("road workbench tests passed");
|
||||
@@ -2,7 +2,7 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("assert");
|
||||
const { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
|
||||
const { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
|
||||
|
||||
function node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
|
||||
function way(id, refs, tags) { return { id, refs, tags }; }
|
||||
@@ -35,6 +35,9 @@ assert.ok(Math.abs((Math.min(...shaftXs) + Math.max(...shaftXs)) / 2) < 1e-9, "t
|
||||
const rightXs = templateFor("right", manifest).flat().map(([x]) => x);
|
||||
assert.ok(Math.max(...rightXs) - Math.min(...rightXs) < 1.7, "source SVG scale stays comparable to existing lane arrows");
|
||||
assert.equal(templateFor("through;left;right", manifest).length, 5, "triple maneuver has a shared straight shaft and two branches");
|
||||
const nativePlacement = arrowRingsAt("left", [114, 30], [0, 1], manifest);
|
||||
assert.ok(nativePlacement.length > 0 && nativePlacement.every((ring) => ring.every((point) => Number.isFinite(point[0]) && Number.isFinite(point[1]))));
|
||||
assert.deepEqual(arrowRingsAt("slight_left", [114, 30], [0, 1], manifest), []);
|
||||
const disabled = buildCustomTurnLaneArrows(osm, { enabled: false, manifest });
|
||||
assert.equal(disabled.features.length, 0);
|
||||
assert.equal(disabled.diagnostics[0].reason, "disabled");
|
||||
|
||||
@@ -1 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}.segmented{display:flex;margin:0 0 8px}.segmented button{flex:1;border-radius:0;padding:6px 4px;font-size:12px}.segmented button+button{border-left:0}.segmented button:first-child{border-radius:3px 0 0 3px}.segmented button:last-child{border-radius:0 3px 3px 0}.segmented button.active{background:#286956;border-color:#286956;color:#fff}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}dl{display:grid;grid-template-columns:1fr auto;gap:5px 10px;margin:0}dt{color:#52615b}dd{margin:0;font-variant-numeric:tabular-nums}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}
|
||||
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}#dirty-state.dirty{color:#ffe08a;font-weight:700}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}button:disabled{cursor:default;opacity:.55}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}.segmented{display:flex;margin:0 0 8px}.segmented button{flex:1;border-radius:0;padding:6px 4px;font-size:12px}.segmented button+button{border-left:0}.segmented button:first-child{border-radius:3px 0 0 3px}.segmented button:last-child{border-radius:0 3px 3px 0}.segmented button.active{background:#286956;border-color:#286956;color:#fff}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}dl{display:grid;grid-template-columns:1fr auto;gap:5px 10px;margin:0}dt{color:#52615b}dd{margin:0;font-variant-numeric:tabular-nums}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}
|
||||
|
||||
@@ -37,44 +37,81 @@ const connectionsBox = document.querySelector("#connections");
|
||||
const addConnectionButton = document.querySelector("#add-connection");
|
||||
const saveButton = document.querySelector("#save");
|
||||
const compileButton = document.querySelector("#compile");
|
||||
const dirtyState = document.querySelector("#dirty-state");
|
||||
const scenePreviewToggle = document.querySelector("#scene-preview");
|
||||
const selectedJunctionPanel = document.querySelector("#selected-junction");
|
||||
const junctionDetail = document.querySelector("#junction-detail");
|
||||
const directionArrowsToggle = document.createElement("label");
|
||||
directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头';
|
||||
const markingsToggle = document.createElement("label");
|
||||
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头';
|
||||
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle);
|
||||
|
||||
let state;
|
||||
let selectedRoad = null;
|
||||
let selectedMovement = null;
|
||||
let selectedJunction = null;
|
||||
let staged = [];
|
||||
let manualFromEndpoint = null;
|
||||
let diagnosticFilter = "all";
|
||||
let scenePreview = false;
|
||||
const source = () => new VectorSource();
|
||||
const layers = {
|
||||
reference: new VectorLayer({ source: source(), style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
|
||||
native: new VectorLayer({ source: source(), style: (feature) => feature.get("native_id")?.startsWith("junction:") ? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) }) : new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) }) }),
|
||||
reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
|
||||
native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }),
|
||||
sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }),
|
||||
osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }),
|
||||
lanes: new VectorLayer({ source: source(), style: laneStyle }),
|
||||
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
|
||||
markings: new VectorLayer({ source: source(), style: markingStyle }),
|
||||
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
|
||||
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
|
||||
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
|
||||
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
|
||||
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
|
||||
};
|
||||
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.osm, layers.lanes, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection], 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.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.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
|
||||
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) });
|
||||
map.addInteraction(select);
|
||||
select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null; selectRoad(roadForFeature(feature), undefined, movement); });
|
||||
select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); const junction = junctionForFeature(feature); if (junction) return selectJunction(junction); const marking = feature.get("provenance")?.startsWith("native-road-"); if (marking) { const road = roadForFeature(feature); const directionArrow = feature.get("provenance") === "native-road-direction-arrow/v1"; const turnArrow = feature.get("provenance") === "native-road-turn-arrow/v1"; const markingType = directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线"; selectRoad(road); evidence.textContent = JSON.stringify({ 标线类型: markingType, OSM道路: feature.get("osm_way_ids"), 方向: feature.get("direction"), 车道: feature.get("lane_index") || `${feature.get("left_lane_index")} 与 ${feature.get("right_lane_index")} 之间`, 转向: turnArrow ? feature.get("maneuver") : null, 道路内距离米: feature.get("distance_along_lane_meters") || null, 路口前距离米: feature.get("placement_distance_meters") || null, 来源: feature.get("provenance") }, null, 2); return message(`已选中${markingType}`); } const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null; selectRoad(roadForFeature(feature), undefined, movement); });
|
||||
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
|
||||
|
||||
function message(text) { status.textContent = text; }
|
||||
function updateDirtyState() {
|
||||
const count = staged.length;
|
||||
dirtyState.textContent = count ? `未保存修改 ${count} 项` : "所有修改已保存";
|
||||
dirtyState.classList.toggle("dirty", count > 0);
|
||||
saveButton.disabled = count === 0;
|
||||
}
|
||||
function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; }
|
||||
function osmDirectionLabel(road) { return road?.direction === "forward" ? "沿 OSM 方向" : "逆 OSM 方向"; }
|
||||
function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
|
||||
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 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.
|
||||
if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) });
|
||||
return feature.get("native_id")?.startsWith("junction:")
|
||||
? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) })
|
||||
: new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) });
|
||||
}
|
||||
function sidewalkSurfaceStyle() {
|
||||
return scenePreview
|
||||
? new Style({ fill: new Fill({ color: "#b7b9ad" }) })
|
||||
: new Style({ fill: new Fill({ color: "rgba(218, 191, 137, .6)" }), stroke: new Stroke({ color: "#9b7c40", width: 1 }) });
|
||||
}
|
||||
function directionArrowFeature(geometry) { const middle = geometry.getCoordinateAt(.5); const before = geometry.getCoordinateAt(.48); const after = geometry.getCoordinateAt(.52); const length = Math.hypot(after[0] - before[0], after[1] - before[1]); if (length < .01) return null; return new Feature({ geometry: new Point(middle), rotation: Math.atan2(after[1] - before[1], after[0] - before[0]) }); }
|
||||
function refreshOsmDirection() { const directionSource = layers.osmDirection.getSource(); directionSource.clear(); if (!selectedRoad) return; const centerline = new LineString(selectedRoad.centerline).transform("EPSG:4326", "EPSG:3857"); const arrow = directionArrowFeature(centerline); if (arrow) directionSource.addFeature(arrow); }
|
||||
function refreshSelectedMovement() { const movementSource = layers.selectedMovement.getSource(); movementSource.clear(); if (!selectedMovement?.geometryPublished || !effectiveConnectorEnabled({ connection_id: selectedMovement.connectionId, fromLaneId: selectedMovement.fromLaneId, toLaneId: selectedMovement.toLaneId })) return; const feature = layers.connectors.getSource().getFeatures().find((candidate) => candidate.get("movement_id") === selectedMovement.id); if (feature) movementSource.addFeature(new Feature({ geometry: feature.getGeometry().clone() })); }
|
||||
function roadForFeature(feature) {
|
||||
const properties = feature.getProperties();
|
||||
const roadId = properties.road_id || properties.subjectId || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
|
||||
return state.compiled.model.roads.find((road) => road.id === roadId) || null;
|
||||
}
|
||||
function junctionForFeature(feature) { const id = feature.get("native_id"); return id?.startsWith("junction:") ? layers.native.getSource().getFeatures().find((item) => item.get("native_id") === id) : null; }
|
||||
function endpointFor(road, side) { return state.compiled.model.endpoints.find((endpoint) => endpoint.roadId === road?.id && endpoint.side === side) || null; }
|
||||
function endpointsCompatible(from, to) { if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false; if (from.nodeId === to.nodeId) return true; const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180); const dy = (from.coordinate[1] - to.coordinate[1]) * 111320; return Math.hypot(dx, dy) <= 35; }
|
||||
function readFeatures(collection) { return geojson.readFeatures(collection || { type: "FeatureCollection", features: [] }, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); }
|
||||
@@ -82,8 +119,11 @@ function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new
|
||||
function updateSources() {
|
||||
layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface));
|
||||
layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]);
|
||||
layers.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface));
|
||||
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
|
||||
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.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors));
|
||||
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
|
||||
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
|
||||
@@ -94,16 +134,24 @@ function effectiveConnectionEnabled(connection) { const id = `连接:${connectio
|
||||
function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
|
||||
|
||||
function selectRoad(road, note, movement = null) {
|
||||
selectedRoad = road; selectedMovement = movement; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection();
|
||||
selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement();
|
||||
layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") }));
|
||||
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
|
||||
roadName.textContent = `${roadLabel(road)}(${osmDirectionLabel(road)})`;
|
||||
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
|
||||
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 当前方向节点顺序: road.sourceNodeIds, 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
||||
laneConvention.textContent = road.laneCount === 1 ? "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。本方向只有一条车道。" : "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。车道按行驶方向从左向右编号。";
|
||||
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || `已选中:${roadLabel(road)}`);
|
||||
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`));
|
||||
}
|
||||
function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${labels[selectedMovement.turn] || selectedMovement.turn}:${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))} → ${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}(${osmDirectionLabel(targetRoad)})\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; }
|
||||
function selectJunction(feature) {
|
||||
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
|
||||
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
|
||||
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);
|
||||
junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2);
|
||||
message(`已选中路口:OSM 节点 ${properties.osm_node_id}`);
|
||||
}
|
||||
function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; }
|
||||
function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${turnLabel(selectedMovement.turn)}:${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))} → ${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}(${osmDirectionLabel(targetRoad)})\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; }
|
||||
function renderDirectionSwitch(road) {
|
||||
directionSwitch.innerHTML = ""; const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
||||
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路"; return; }
|
||||
@@ -119,17 +167,32 @@ function renderConnections(road) {
|
||||
function renderManualCandidates(endpoint, road) { const candidates = state.compiled.diagnostics.find((item) => item.endpointId === endpoint?.id)?.manualCandidates || []; if (!candidates.length) return; const title = document.createElement("p"); title.textContent = "附近可手工连接的驶出方向"; connectionsBox.append(title); for (const candidate of candidates) { const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId); if (!target) continue; const button = document.createElement("button"); button.type = "button"; button.textContent = `${roadLabel(target)}(${candidate.distanceMeters} 米)`; button.onclick = () => { stageConnection({ id: `connection:${endpoint.id}:${candidate.toEndpointId}`, fromEndpointId: endpoint.id, toEndpointId: candidate.toEndpointId }, true); selectRoad(road, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }; connectionsBox.append(button); } }
|
||||
function renderLaneControls(connection) { const rows = state.compiled.movements?.filter((movement) => movement.connectionId === connection.id) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === row.toRoadId); const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad, "有未保存修改:转向路径已即时更新"); }; const geometryNote = row.geometryStatus === "continuous" ? ",节点连续" : row.geometryStatus === "deferred-too-long" ? ",路径过长未绘制" : ""; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.fromLaneId))} → ${lanePositionLabel(targetRoad, laneIndex(row.toLaneId))}(${osmDirectionLabel(targetRoad)}${geometryNote})`); connectionsBox.append(label); } }
|
||||
function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; }
|
||||
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); layers.connectors.changed(); }
|
||||
function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); }
|
||||
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); layers.connectors.changed(); updateDirtyState(); }
|
||||
function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); updateDirtyState(); }
|
||||
function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }
|
||||
addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); };
|
||||
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
|
||||
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
|
||||
function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}(${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; }
|
||||
function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}(${counts[filter]})`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
|
||||
function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } }
|
||||
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selectedRoad.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selectedRoad.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); };
|
||||
saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) return message(result.error); state.overrides = result.overrides; staged = []; message("已保存,点击“保存并重新生成”写入几何"); };
|
||||
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已重新生成"); };
|
||||
function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["普通构面路口", comparison.nativeApproachEnvelopeJunctions], ["兜底构面路口", comparison.nativeFallbackJunctions], ["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio], ["道路方向箭头", comparison.nativeDirectionArrowFeatures], ["路口转向箭头", comparison.nativeTurnArrowFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } }
|
||||
function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); }
|
||||
form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
|
||||
async function saveStagedChanges() { if (!staged.length) return true; const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }
|
||||
saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); };
|
||||
compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已保存并重新生成"); };
|
||||
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(input.checked); };
|
||||
scenePreviewToggle.onchange = () => {
|
||||
scenePreview = scenePreviewToggle.checked;
|
||||
for (const input of document.querySelectorAll("[data-layer]")) {
|
||||
const layer = input.dataset.layer;
|
||||
if (["osm", "lanes", "reference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked);
|
||||
}
|
||||
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.diagnostics.setVisible(!scenePreview);
|
||||
layers.native.changed(); layers.sidewalks.changed();
|
||||
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
|
||||
};
|
||||
for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); };
|
||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|
||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
|
||||
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
|
||||
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道或转向路径以查看详情。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.1"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有人行道</label><label><input id="right" type="checkbox"> 右侧有人行道</label><button type="submit">暂存本道路修改</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence">无</pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>
|
||||
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
|
||||
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence">无</pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user