feat: add native road compiler workbench
This commit is contained in:
3
.trellis/tasks/08-13-native-road-compiler/check.jsonl
Normal file
3
.trellis/tasks/08-13-native-road-compiler/check.jsonl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{"file":".trellis/spec/pipeline/index.md","reason":"Check command ownership, disk artifact boundaries, and legacy pipeline compatibility."}
|
||||||
|
{"file":".trellis/spec/preview/index.md","reason":"Check browser workbench state, error handling, and no-build browser constraints."}
|
||||||
|
{"file":".trellis/spec/config/index.md","reason":"Check new area output/config normalization and compatibility."}
|
||||||
100
.trellis/tasks/08-13-native-road-compiler/design.md
Normal file
100
.trellis/tasks/08-13-native-road-compiler/design.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Native Road Compiler Workbench Design
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The native compiler is an additive pipeline path. It owns a separate output
|
||||||
|
directory under an area and never writes into `osm2streets_web_out/`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
OSM XML + native-road-overrides.json
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Canonical Road Model
|
||||||
|
roads / endpoints / junction candidates / provenance
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Native Geometry Compiler
|
||||||
|
road surfaces / initial junction surfaces / diagnostics
|
||||||
|
|
|
||||||
|
+--> native-road/compiled.json
|
||||||
|
+--> native-road/layers/*.geojson
|
||||||
|
+--> native-road/diagnostics.json
|
||||||
|
+--> native-road/comparison.json
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Road Workbench HTTP service
|
||||||
|
browser map + inspect/edit/save API
|
||||||
|
```
|
||||||
|
|
||||||
|
The canonical model is the authority. Render layers, browser display data, and
|
||||||
|
future Blender compatibility adapters are derived from it.
|
||||||
|
|
||||||
|
## Commands And Ownership
|
||||||
|
|
||||||
|
- `npm run road:compile -- --config <area-config>` performs no browser work.
|
||||||
|
It reads OSM plus the persisted override file, writes a staged native-road
|
||||||
|
result, validates it, and atomically promotes the result directory.
|
||||||
|
- `npm run road:workbench -- --config <area-config>` compiles first unless
|
||||||
|
`--no-compile` is supplied, then starts a local HTTP server scoped to that
|
||||||
|
one area.
|
||||||
|
- The server exposes read-only compiler artifacts and one explicit save API
|
||||||
|
for validated overrides. It does not expose arbitrary filesystem paths.
|
||||||
|
- Existing `build:area`, `intermediates`, QGIS, Blender, Cesium, and package
|
||||||
|
paths remain unchanged in the first iteration.
|
||||||
|
|
||||||
|
## Data Contracts
|
||||||
|
|
||||||
|
### Canonical road model
|
||||||
|
|
||||||
|
Each road direction carries a stable ID derived from OSM identifiers, source
|
||||||
|
way IDs, endpoint node IDs, centerline, explicit/inferred attributes, applied
|
||||||
|
override IDs, and diagnostics. Junction candidates likewise use their OSM node
|
||||||
|
ID when available. Values include provenance such as `tag:lanes:forward`,
|
||||||
|
`inferred:highway-default`, or `override:<id>`.
|
||||||
|
|
||||||
|
### Override file
|
||||||
|
|
||||||
|
`<area>/native-road-overrides.json` is versioned and human-reviewable. It
|
||||||
|
contains an array of uniquely identified changes whose targets are stable road
|
||||||
|
or endpoint IDs. Supported v1 records are `road` parameter overrides and
|
||||||
|
`junction-connection` decisions. The save endpoint validates schema, target
|
||||||
|
existence, finite values, and duplicate/conflicting edits before atomic write.
|
||||||
|
|
||||||
|
### Compiler artifacts
|
||||||
|
|
||||||
|
`<area>/native-road/compiled.json` is the workbench's single read model.
|
||||||
|
`layers/` contains generated GeoJSON with source/provenance properties.
|
||||||
|
`diagnostics.json` contains severity, stable subject ID, source IDs, rule,
|
||||||
|
message, and optional geometry. `comparison.json` reports counts and coverage
|
||||||
|
against available osm2streets layers; it does not claim quality solely from
|
||||||
|
visual differences.
|
||||||
|
|
||||||
|
## Browser Workbench
|
||||||
|
|
||||||
|
The browser uses no framework or map runtime in v1. A Canvas/SVG map renders
|
||||||
|
fit-to-data OSM centerlines, native surfaces, optional osm2streets reference
|
||||||
|
layers, diagnostics, selected-object provenance, and overrides. This keeps the
|
||||||
|
first interactive path dependency-free and permits precise local coordinates.
|
||||||
|
|
||||||
|
The user can select a road or endpoint, edit only v1 fields, inspect the
|
||||||
|
resulting override record, explicitly save it, and recompile/reload. Saved
|
||||||
|
state is visibly differentiated from unsaved state. The workbench must not
|
||||||
|
offer freehand final-polygon editing, since that would break reproducibility.
|
||||||
|
|
||||||
|
## Geometry And Validation
|
||||||
|
|
||||||
|
V1 produces road segments from projected centerline offsets and terminal
|
||||||
|
cross-sections. It only generates a junction surface when endpoints satisfy
|
||||||
|
the supported ordinary T/cross shape and geometry checks; otherwise it emits a
|
||||||
|
diagnostic rather than inventing an invalid polygon. Validation detects
|
||||||
|
dangling endpoints, unclosed/self-intersecting rings, non-finite coordinates,
|
||||||
|
unsupported multi-level intersections, and source/topology ambiguity. Small
|
||||||
|
numerical cleanup may be explicit and recorded; semantic failures are never
|
||||||
|
silently repaired.
|
||||||
|
|
||||||
|
## Compatibility And Rollout
|
||||||
|
|
||||||
|
The first compiler's layers use existing render-layer names where meaningful,
|
||||||
|
but are stored separately. A later, explicitly enabled Blender provider option
|
||||||
|
may consume native layers after comparison gates pass. Delete/replace behavior
|
||||||
|
is out of scope; rollback is selecting the existing osm2streets pipeline.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"file":".trellis/spec/pipeline/index.md","reason":"Native compiler commands, artifacts, and area config extend the Node pipeline while preserving legacy stages."}
|
||||||
|
{"file":".trellis/spec/preview/index.md","reason":"The browser workbench is a new DOM runtime and must follow local preview loading and state conventions where applicable."}
|
||||||
|
{"file":".trellis/spec/config/index.md","reason":"New native-road output paths and config behavior extend the normalized area contract."}
|
||||||
32
.trellis/tasks/08-13-native-road-compiler/implement.md
Normal file
32
.trellis/tasks/08-13-native-road-compiler/implement.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
1. Add area output/config normalization and command entrypoints for the native
|
||||||
|
compiler, preserving existing stage behavior and paths.
|
||||||
|
2. Implement a shared OSM road parser and canonical road/endpoints model with
|
||||||
|
source provenance, explicit versus inferred properties, and stable IDs.
|
||||||
|
3. Implement v1 override schema, validation, load/apply behavior, atomic save,
|
||||||
|
and focused unit tests.
|
||||||
|
4. Implement projected road segment geometry, supported T/cross junction
|
||||||
|
detection, native GeoJSON artifact emission, diagnostics, and comparison
|
||||||
|
summary.
|
||||||
|
5. Implement a dependency-free local workbench server and browser UI with
|
||||||
|
selection, provenance display, v1 parameter/topology editing, explicit save,
|
||||||
|
compile/reload, and error states.
|
||||||
|
6. Add native compiler tests using focused fixtures plus nantaizi analysis;
|
||||||
|
run existing relevant Node tests to confirm legacy behavior remains intact.
|
||||||
|
7. Compare nantaizi and at least one supplied problematic OSM sample. Record
|
||||||
|
metrics, unsupported cases, and follow-up work in task research.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||||
|
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||||
|
npm run test:build-stages
|
||||||
|
npm run test:preflight
|
||||||
|
npm run test:preview-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser validation includes loading the workbench, editing a road parameter,
|
||||||
|
saving, verifying the override file, recompiling, reloading, and confirming
|
||||||
|
provenance identifies the saved override.
|
||||||
93
.trellis/tasks/08-13-native-road-compiler/prd.md
Normal file
93
.trellis/tasks/08-13-native-road-compiler/prd.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Native road compiler workbench
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Build an incremental native road compiler for Chinese urban and campus OSM
|
||||||
|
data that can progressively exceed osm2streets in geometry quality,
|
||||||
|
explainability, and repeatable correction. The existing osm2streets pipeline
|
||||||
|
must remain usable while the native compiler is developed and compared.
|
||||||
|
|
||||||
|
The first deliverable is a browser-based Road Workbench. It must expose the
|
||||||
|
native compiler's source data, generated geometry, and diagnostics, allow
|
||||||
|
users to make small semantic/topology corrections, persist those corrections
|
||||||
|
as versionable overrides, and reload them automatically in later runs.
|
||||||
|
|
||||||
|
## Confirmed Facts
|
||||||
|
|
||||||
|
- Nantaizi currently works relatively well because its OSM data received
|
||||||
|
deliberate supplemental tagging; it still has missing boundaries and
|
||||||
|
polygons that cannot be closed.
|
||||||
|
- Other tested OSM inputs expose osm2streets sensitivity to input structure
|
||||||
|
and leave too much opaque, final-polygon repair work in QGIS.
|
||||||
|
- Existing Blender consumes the nine GeoJSON render layers from
|
||||||
|
`osm2streets_web_out/`; QGIS GeoPackage edits can currently be reimported
|
||||||
|
only as a whole batch.
|
||||||
|
- The repository has no existing interactive browser editing service. Existing
|
||||||
|
Cesium preview is a static, generated verification page.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- R1: Add a native-road-compiler path without replacing or regressing the
|
||||||
|
existing osm2streets path.
|
||||||
|
- R2: Parse OSM into a canonical, source-traceable road model with stable
|
||||||
|
references to OSM ways and nodes, explicit values versus inferred values,
|
||||||
|
and diagnostics.
|
||||||
|
- R3: Compile at least ordinary road segments and the initial supported
|
||||||
|
junction subset into the existing render-layer contract, allowing existing
|
||||||
|
Blender/Cesium consumers to be reused.
|
||||||
|
- R4: Provide a browser Road Workbench that overlays raw OSM topology,
|
||||||
|
generated geometry, osm2streets comparison geometry when available, and
|
||||||
|
compiler diagnostics.
|
||||||
|
- R5: The workbench must permit scoped user adjustments and save them to an
|
||||||
|
area-local, human-reviewable override file. Future compile and workbench
|
||||||
|
runs must load that file automatically.
|
||||||
|
- R6: Each generated object and diagnostic must be traceable to OSM source
|
||||||
|
IDs, compiler rule/inference evidence, and relevant override IDs.
|
||||||
|
- R7: Validate topology and geometry before publishing generated layers;
|
||||||
|
report unresolved semantic errors instead of silently disguising them as
|
||||||
|
geometric repair.
|
||||||
|
- R8: Develop against nantaizi plus problem inputs and report native versus
|
||||||
|
osm2streets comparison metrics.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- First implementation targets Chinese urban/campus roads, ordinary road
|
||||||
|
segments, T/cross junctions, directed/multi-lane roads, and data already
|
||||||
|
tagged in nantaizi where possible.
|
||||||
|
- Existing Blender, Cesium export, package format, building, vegetation, and
|
||||||
|
water generators are out of scope unless a compatibility adapter requires a
|
||||||
|
narrowly scoped change.
|
||||||
|
- Directly editing final render polygons is not the intended correction model;
|
||||||
|
generated layers remain derived output.
|
||||||
|
- Complex interchanges, arbitrary multilayer junctions, and full worldwide OSM
|
||||||
|
coverage are deferred until driven by concrete samples.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] A native compile command produces a canonical road model, generated
|
||||||
|
layers, diagnostics, and comparison artifacts for a configured area without
|
||||||
|
changing the osm2streets output path.
|
||||||
|
- [ ] A browser command serves a Road Workbench for an area and clearly shows
|
||||||
|
source topology, generated output, diagnostics, provenance, and saved
|
||||||
|
overrides.
|
||||||
|
- [ ] A user can make the agreed first-scope override edits in the browser,
|
||||||
|
save them explicitly, and receive a durable area-local override artifact.
|
||||||
|
- [ ] Re-running compile or reopening the workbench applies saved overrides
|
||||||
|
automatically and exposes their provenance.
|
||||||
|
- [ ] The compiler reports invalid/unclosed geometry, dangling road ends,
|
||||||
|
and unresolved junction/lane ambiguity with source IDs.
|
||||||
|
- [ ] Nantaizi and at least one known problematic area can run through the
|
||||||
|
native analysis/preview path, with comparison metrics captured rather than
|
||||||
|
a claim based only on visual inspection.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- The first browser editing surface supports road parameters (width, directed
|
||||||
|
lane counts, left/right sidewalk state) plus junction endpoint
|
||||||
|
connect/disconnect decisions.
|
||||||
|
- Turn restrictions, stop lines, and crosswalk placement are deferred until
|
||||||
|
the compiler has a validated road/junction editing loop.
|
||||||
|
- Overrides are a versioned, human-reviewable JSON artifact owned by the area,
|
||||||
|
not edits to generated polygon layers.
|
||||||
|
- Native output and osm2streets output remain parallel during development;
|
||||||
|
neither silently overwrites the other.
|
||||||
26
.trellis/tasks/08-13-native-road-compiler/task.json
Normal file
26
.trellis/tasks/08-13-native-road-compiler/task.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "native-road-compiler",
|
||||||
|
"name": "native-road-compiler",
|
||||||
|
"title": "Native road compiler workbench",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "dingkang",
|
||||||
|
"assignee": "dingkang",
|
||||||
|
"createdAt": "2026-08-13",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "feature/native-road-compiler",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
24
README.md
24
README.md
@@ -96,6 +96,30 @@ npm run preflight:area -- --config config/areas/nantaizi-lake-innovation-valley.
|
|||||||
预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次
|
预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次
|
||||||
验证的 config / OSM 文件摘要和检查结果。
|
验证的 config / OSM 文件摘要和检查结果。
|
||||||
|
|
||||||
|
## Native Road Workbench
|
||||||
|
|
||||||
|
`road:compile` 是独立于 osm2streets 的实验性道路编译器入口。它从 OSM 生成可追溯的
|
||||||
|
道路模型、基础道路面、诊断与对比摘要,写入 `outputs/<area-id>/native-road/`,不会覆盖
|
||||||
|
`osm2streets_web_out/` 或影响现有 Blender/Cesium 构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||||
|
```
|
||||||
|
|
||||||
|
启动本地浏览器工作台:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
|
||||||
|
```
|
||||||
|
|
||||||
|
工作台默认先编译,并在 `http://127.0.0.1:8787/` 展示 OSM 道路、原生结果、诊断和每个值的
|
||||||
|
来源。可编辑道路宽度、车道数、两侧人行道,以及候选路口端点连接。保存写入
|
||||||
|
`outputs/<area-id>/native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和
|
||||||
|
启动工作台时会自动加载。编辑不会直接修改最终 polygon。
|
||||||
|
|
||||||
|
道路按行驶方向显示:点击道路的任意线段后,右侧“路口连接”只列出该方向到达终点路口后
|
||||||
|
可驶入的目标道路,并标记为左转、直行、右转或掉头;每一个目标道路只出现一次。
|
||||||
|
|
||||||
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
|
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
|
||||||
现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。
|
现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。
|
||||||
缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`。
|
缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`。
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"compress:glb": "node scripts/compress-glb.js",
|
"compress:glb": "node scripts/compress-glb.js",
|
||||||
"diagnose:area": "node scripts/diagnose-area.js",
|
"diagnose:area": "node scripts/diagnose-area.js",
|
||||||
"preflight:area": "node scripts/preflight-area.js",
|
"preflight:area": "node scripts/preflight-area.js",
|
||||||
|
"road:compile": "node scripts/compile-native-roads.js",
|
||||||
|
"road:workbench": "node scripts/road-workbench.js",
|
||||||
|
"test:native-road": "node scripts/test-native-road.js",
|
||||||
"test:preflight": "node scripts/test-area-preflight.js",
|
"test:preflight": "node scripts/test-area-preflight.js",
|
||||||
"test:build-stages": "node scripts/test-build-stages.js",
|
"test:build-stages": "node scripts/test-build-stages.js",
|
||||||
"test:budgets": "node scripts/test-asset-budgets.js",
|
"test:budgets": "node scripts/test-asset-budgets.js",
|
||||||
|
|||||||
71
scripts/compile-native-roads.js
Normal file
71
scripts/compile-native-roads.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { readAreaConfig } = require("./lib/area-config");
|
||||||
|
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const result = {};
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
if (!argv[index].startsWith("--")) continue;
|
||||||
|
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : "true";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileArea(configPath) {
|
||||||
|
const area = readAreaConfig(configPath, { repoRoot });
|
||||||
|
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
|
||||||
|
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
|
||||||
|
validateOverrides(overrides, model);
|
||||||
|
const compiled = compileGeometry(model);
|
||||||
|
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
|
||||||
|
try {
|
||||||
|
const result = {
|
||||||
|
schema: "native-road-compiled/v1",
|
||||||
|
areaId: area.id,
|
||||||
|
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
|
||||||
|
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
|
||||||
|
diagnostics: compiled.diagnostics,
|
||||||
|
layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson" },
|
||||||
|
};
|
||||||
|
const comparison = compareOsm2Streets(area, result.model.roads.length);
|
||||||
|
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", "intersection_surface.geojson"), compiled.intersectionSurface);
|
||||||
|
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
|
||||||
|
fs.renameSync(staging, area.outputs.nativeRoadDir);
|
||||||
|
return { area, result, comparison };
|
||||||
|
} catch (error) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareOsm2Streets(area, nativeRoadCount) {
|
||||||
|
const source = path.join(area.outputs.geojsonDir, "road_surface.geojson");
|
||||||
|
let featureCount = null;
|
||||||
|
if (fs.existsSync(source)) {
|
||||||
|
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
|
||||||
|
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
|
||||||
|
}
|
||||||
|
return { schema: "native-road-comparison/v1", nativeRoadCount, osm2streetsRoadSurfaceFeatures: featureCount, osm2streetsAvailable: featureCount !== null, note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review." };
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||||
|
const { area, result, comparison } = compileArea(configPath);
|
||||||
|
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: area.id, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: area.outputs.nativeRoadDir, comparison })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { compileArea, parseArgs };
|
||||||
@@ -32,9 +32,12 @@ function normalizeAreaConfig(raw, options = {}) {
|
|||||||
const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime"));
|
const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime"));
|
||||||
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
|
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
|
||||||
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
|
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
|
||||||
|
const nativeRoadDir = path.resolve(outputOverrides.nativeRoadDir || path.join(areaDir, "native-road"));
|
||||||
const outputs = {
|
const outputs = {
|
||||||
areaDir,
|
areaDir,
|
||||||
geojsonDir,
|
geojsonDir,
|
||||||
|
nativeRoadDir,
|
||||||
|
nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")),
|
||||||
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
|
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
|
||||||
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
|
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
|
||||||
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
|
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),
|
||||||
|
|||||||
229
scripts/lib/native-road.js
Normal file
229
scripts/lib/native-road.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
function parseOsmRoads(xml) {
|
||||||
|
const nodes = new Map();
|
||||||
|
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
|
||||||
|
const attrs = xmlAttrs(match[1]);
|
||||||
|
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||||
|
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
||||||
|
if (coordinate.every(Number.isFinite)) nodes.set(String(attrs.id), coordinate);
|
||||||
|
}
|
||||||
|
const ways = [];
|
||||||
|
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||||
|
const attrs = xmlAttrs(match[1]);
|
||||||
|
const body = match[2];
|
||||||
|
const tags = parseTags(body);
|
||||||
|
if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue;
|
||||||
|
const refs = [...body.matchAll(/<nd\b([^>]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean);
|
||||||
|
const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean);
|
||||||
|
if (coords.length < 2 || coords.length !== refs.length) continue;
|
||||||
|
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
|
||||||
|
}
|
||||||
|
return { nodes, ways };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileRoadModel(xml, overrides) {
|
||||||
|
const parsed = parseOsmRoads(xml);
|
||||||
|
const diagnostics = [];
|
||||||
|
const roads = [];
|
||||||
|
const endpoints = [];
|
||||||
|
const byNode = new Map();
|
||||||
|
for (const way of parsed.ways) {
|
||||||
|
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: [] };
|
||||||
|
applyRoadOverrides(road, overrides, diagnostics);
|
||||||
|
roads.push(road);
|
||||||
|
for (const side of ["start", "end"]) {
|
||||||
|
const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1];
|
||||||
|
const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction };
|
||||||
|
endpoints.push(endpoint);
|
||||||
|
if (!byNode.has(nodeId)) byNode.set(nodeId, []);
|
||||||
|
byNode.get(nodeId).push(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
|
||||||
|
const extent = roadExtent(roads);
|
||||||
|
for (const [nodeId, items] of byNode) {
|
||||||
|
if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) {
|
||||||
|
const endpoint = items[0];
|
||||||
|
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
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])) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToExtentEdgeMeters(point, extent) {
|
||||||
|
const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180);
|
||||||
|
return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roadAttributes(tags, direction) {
|
||||||
|
const directional = direction === "forward" ? "forward" : "backward";
|
||||||
|
const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null);
|
||||||
|
const parsedLanes = positiveInteger(laneTag);
|
||||||
|
const totalLanes = positiveInteger(tags.lanes);
|
||||||
|
const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1);
|
||||||
|
const parsedWidth = positiveNumber(tags.width);
|
||||||
|
const forwardLanes = positiveInteger(tags["lanes:forward"]);
|
||||||
|
const backwardLanes = positiveInteger(tags["lanes:backward"]);
|
||||||
|
const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes;
|
||||||
|
// `width` describes the whole OSM way. A directional road receives its lane
|
||||||
|
// share; absent width falls back to a realistic per-lane carriageway width.
|
||||||
|
const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25;
|
||||||
|
return {
|
||||||
|
laneCount: lanes,
|
||||||
|
widthMeters: width,
|
||||||
|
sidewalkLeft: sidewalkState(tags, direction, "left"),
|
||||||
|
sidewalkRight: sidewalkState(tags, direction, "right"),
|
||||||
|
provenance: {
|
||||||
|
laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes",
|
||||||
|
widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidewalkState(tags, direction, side) {
|
||||||
|
const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left";
|
||||||
|
const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk;
|
||||||
|
return value === "both" || value === "yes" || value === osmSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOverrides(file) {
|
||||||
|
if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] };
|
||||||
|
return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
|
||||||
|
for (const item of value.overrides) {
|
||||||
|
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
|
||||||
|
ids.add(item.id);
|
||||||
|
if (item.kind === "road") {
|
||||||
|
if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`);
|
||||||
|
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`);
|
||||||
|
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
|
||||||
|
} else if (item.kind === "junction-connection") {
|
||||||
|
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
|
||||||
|
} else throw new Error(`Unsupported override kind: ${item.kind}`);
|
||||||
|
}
|
||||||
|
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoadOverrides(road, overrides, diagnostics) {
|
||||||
|
for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnections(endpoints, byNode, overrides, diagnostics) {
|
||||||
|
const result = [];
|
||||||
|
for (const [nodeId, items] of byNode) {
|
||||||
|
const arrivals = items.filter((endpoint) => endpoint.side === "end");
|
||||||
|
const departures = items.filter((endpoint) => endpoint.side === "start");
|
||||||
|
for (const arrival of arrivals) for (const departure of departures) {
|
||||||
|
if (arrival.roadId === departure.roadId) continue;
|
||||||
|
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
|
||||||
|
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
|
||||||
|
}
|
||||||
|
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileGeometry(model) {
|
||||||
|
const diagnostics = [...model.diagnostics];
|
||||||
|
const features = [];
|
||||||
|
const emittedWays = 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 totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
||||||
|
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 junctionFeatures = ordinaryJunctionFeatures(model, diagnostics);
|
||||||
|
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinaryJunctionFeatures(model, 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);
|
||||||
|
}
|
||||||
|
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 roads = endpoints.map((endpoint) => model.roads.find((road) => road.id === endpoint.roadId));
|
||||||
|
const radius = Math.max(...roads.map((road) => road.widthMeters)) * 0.65;
|
||||||
|
const ring = circleRing(endpoints[0].coordinate, radius, 16);
|
||||||
|
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), rule: "ordinary-junction-disc/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||||
|
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "Generated a conservative ordinary junction surface; connector geometry is deferred.", endpoints[0].coordinate));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleRing(center, radius, segments) {
|
||||||
|
const origin = center;
|
||||||
|
const ring = [];
|
||||||
|
for (let index = 0; index <= segments; index += 1) {
|
||||||
|
const angle = index / segments * Math.PI * 2;
|
||||||
|
ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin));
|
||||||
|
}
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roadRing(line, width) {
|
||||||
|
if (line.length < 2 || !Number.isFinite(width)) return null;
|
||||||
|
const origin = line[0];
|
||||||
|
const points = line.map((point) => project(point, origin));
|
||||||
|
const left = []; const right = [];
|
||||||
|
const half = width / 2;
|
||||||
|
for (let i = 0; i < points.length; i += 1) {
|
||||||
|
const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)];
|
||||||
|
const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy);
|
||||||
|
if (length < 0.01) return null;
|
||||||
|
const nx = -dy / length * half; const ny = dx / length * half;
|
||||||
|
left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin));
|
||||||
|
right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin));
|
||||||
|
}
|
||||||
|
const ring = [...left, ...right.reverse(), left[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 }; }
|
||||||
|
function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; }
|
||||||
|
function parseTags(body) { const tags = {}; for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; }
|
||||||
|
function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; }
|
||||||
|
function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; }
|
||||||
|
function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); }
|
||||||
|
|
||||||
|
module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };
|
||||||
58
scripts/road-workbench.js
Normal file
58
scripts/road-workbench.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const http = require("http");
|
||||||
|
const path = require("path");
|
||||||
|
const { readAreaConfig } = require("./lib/area-config");
|
||||||
|
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
||||||
|
const { compileArea, parseArgs } = require("./compile-native-roads");
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
|
||||||
|
if (args.noCompile !== "true") compileArea(configPath);
|
||||||
|
const area = readAreaConfig(configPath, { repoRoot });
|
||||||
|
const port = Number(args.port || 8787);
|
||||||
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
|
||||||
|
const server = http.createServer((request, response) => handle(request, response, area, configPath));
|
||||||
|
server.on("error", (error) => {
|
||||||
|
console.error(`Road Workbench failed to listen: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle(request, response, area, configPath) {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
|
||||||
|
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
|
||||||
|
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
|
||||||
|
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
|
||||||
|
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
||||||
|
const compiled = readCompiled(area);
|
||||||
|
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
|
||||||
|
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
|
||||||
|
sendJson(response, 200, { ok: true, overrides });
|
||||||
|
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||||
|
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
|
||||||
|
compileArea(configPath);
|
||||||
|
sendJson(response, 200, state(area));
|
||||||
|
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
|
||||||
|
sendJson(response, 404, { error: "Not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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")), 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")); }
|
||||||
|
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
|
||||||
|
function readBody(request) { return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (part) => { body += part; if (body.length > 1024 * 1024) request.destroy(); }); request.on("end", () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error("Request body must be JSON.")); } }); request.on("error", reject); }); }
|
||||||
|
function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); }
|
||||||
|
function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
|
||||||
|
if (require.main === module) main();
|
||||||
28
scripts/test-native-road.js
Normal file
28
scripts/test-native-road.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("assert");
|
||||||
|
const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road");
|
||||||
|
|
||||||
|
const osm = `<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="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="lanes" v="2"/><tag k="sidewalk" v="both"/></way><way id="11"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
|
||||||
|
const empty = { schema: "native-road-overrides/v1", overrides: [] };
|
||||||
|
const initial = compileRoadModel(osm, empty);
|
||||||
|
assert.equal(initial.roads.length, 3);
|
||||||
|
const target = initial.roads.find((road) => road.id === "road:way/10:forward");
|
||||||
|
const overrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "road-width", kind: "road", roadId: target.id, widthMeters: 9, laneCount: 2, sidewalkLeft: false }] }, initial);
|
||||||
|
const model = compileRoadModel(osm, overrides);
|
||||||
|
const edited = model.roads.find((road) => road.id === target.id);
|
||||||
|
assert.equal(edited.widthMeters, 9);
|
||||||
|
assert.equal(edited.provenance.widthMeters, "override:road-width");
|
||||||
|
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));
|
||||||
|
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);
|
||||||
|
const connectionOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "disconnect", kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled: false }] }, initial);
|
||||||
|
assert.equal(validateOverrides(connectionOverrides).overrides.length, 1);
|
||||||
|
assert.equal(compileRoadModel(osm, connectionOverrides).connections.find((item) => item.id === connection.id).enabled, false);
|
||||||
|
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
|
||||||
|
console.log("native road tests passed");
|
||||||
1
scripts/workbench/app.css
Normal file
1
scripts/workbench/app.css
Normal file
@@ -0,0 +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}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}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}}
|
||||||
116
scripts/workbench/app.js
Normal file
116
scripts/workbench/app.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const canvas = document.querySelector("#map");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const status = document.querySelector("#status");
|
||||||
|
const form = document.querySelector("#road-form");
|
||||||
|
const areaLabel = document.querySelector("#area");
|
||||||
|
const widthInput = document.querySelector("#width");
|
||||||
|
const lanesInput = document.querySelector("#lanes");
|
||||||
|
const leftInput = document.querySelector("#left");
|
||||||
|
const rightInput = document.querySelector("#right");
|
||||||
|
const evidence = document.querySelector("#evidence");
|
||||||
|
const saveButton = document.querySelector("#save");
|
||||||
|
const compileButton = document.querySelector("#compile");
|
||||||
|
const diagnostics = document.querySelector("#diagnostics");
|
||||||
|
const directionSwitch = document.querySelector("#direction-switch");
|
||||||
|
let state;
|
||||||
|
let selected = null;
|
||||||
|
let staged = [];
|
||||||
|
|
||||||
|
function message(text) { status.textContent = text; }
|
||||||
|
function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; }
|
||||||
|
function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b.maxX - b.minX) * canvas.width, canvas.height - (point[1] - b.minY) / (b.maxY - b.minY) * canvas.height]; }
|
||||||
|
function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; }
|
||||||
|
function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); }
|
||||||
|
function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); }
|
||||||
|
function draw() {
|
||||||
|
if (!state) return;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const layers = state.layers || {};
|
||||||
|
for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55");
|
||||||
|
for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666");
|
||||||
|
for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066");
|
||||||
|
for (const road of state.compiled.model.roads) {
|
||||||
|
context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); });
|
||||||
|
context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630";
|
||||||
|
context.lineWidth = (road.id === selected?.id ? 4 : 2) * devicePixelRatio;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
if (selected) drawDirectionArrow(selected);
|
||||||
|
for (const diagnostic of state.compiled.diagnostics) {
|
||||||
|
if (!diagnostic.geometry) continue;
|
||||||
|
const point = coord(diagnostic.geometry.coordinates); const isJunction = diagnostic.rule === "ordinary-junction-surface"; context.fillStyle = isJunction ? "#0e7860" : diagnostic.severity === "error" ? "#bf3b2e" : "#d49318"; context.beginPath(); context.arc(...point, isJunction ? 4 * devicePixelRatio : 5 * devicePixelRatio, 0, Math.PI * 2); context.fill();
|
||||||
|
}
|
||||||
|
if (state.focusedDiagnostic?.geometry) { const point = coord(state.focusedDiagnostic.geometry.coordinates); context.strokeStyle = "#006e91"; context.lineWidth = 3 * devicePixelRatio; context.beginPath(); context.arc(...point, 11 * devicePixelRatio, 0, Math.PI * 2); context.stroke(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDirectionArrow(road) {
|
||||||
|
const middle = Math.max(1, Math.floor(road.centerline.length / 2));
|
||||||
|
const a = coord(road.centerline[middle - 1]); const b = coord(road.centerline[middle]);
|
||||||
|
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); const size = 10 * devicePixelRatio;
|
||||||
|
context.save(); context.translate(b[0], b[1]); context.rotate(angle); context.fillStyle = "#006e91";
|
||||||
|
context.beginPath(); context.moveTo(size, 0); context.lineTo(-size * 0.8, -size * 0.6); context.lineTo(-size * 0.8, size * 0.6); context.closePath(); context.fill(); context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(road) {
|
||||||
|
selected = road; form.hidden = !road; document.querySelector("#hint").hidden = Boolean(road);
|
||||||
|
if (!road) return;
|
||||||
|
document.querySelector("#road-name").textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"})`;
|
||||||
|
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
|
||||||
|
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
||||||
|
renderDirectionSwitch(road);
|
||||||
|
renderConnections(road); draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "单向道路:沿 OSM 节点顺序行驶"; return; }
|
||||||
|
const note = document.createElement("label"); note.textContent = "编辑方向(地图蓝色箭头表示当前方向)"; directionSwitch.append(note);
|
||||||
|
for (const item of alternatives) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序";
|
||||||
|
button.disabled = item.id === road.id; button.onclick = () => select(item); directionSwitch.append(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnections(road) {
|
||||||
|
const box = document.querySelector("#connections"); box.innerHTML = "";
|
||||||
|
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end");
|
||||||
|
const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
|
||||||
|
if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; }
|
||||||
|
const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro);
|
||||||
|
const seen = new Set();
|
||||||
|
for (const connection of rows) {
|
||||||
|
const targetEndpoint = state.compiled.model.endpoints.find((item) => item.id === connection.toEndpointId);
|
||||||
|
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint?.roadId);
|
||||||
|
if (!target || seen.has(target.id)) continue;
|
||||||
|
seen.add(target.id);
|
||||||
|
const input = document.createElement("input"); const label = document.createElement("label");
|
||||||
|
input.type = "checkbox"; input.checked = connection.enabled; input.onchange = () => stageConnection(connection, input.checked);
|
||||||
|
label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); box.append(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnName(from, to) {
|
||||||
|
const a = heading(from.centerline.at(-2), from.centerline.at(-1));
|
||||||
|
const b = heading(to.centerline[0], to.centerline[1]);
|
||||||
|
const delta = ((b - a + 540) % 360) - 180;
|
||||||
|
if (Math.abs(delta) >= 150) return "掉头";
|
||||||
|
if (Math.abs(delta) <= 30) return "直行";
|
||||||
|
return delta > 0 ? "右转" : "左转";
|
||||||
|
}
|
||||||
|
function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; }
|
||||||
|
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 }); message("有未保存修改"); }
|
||||||
|
function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); }
|
||||||
|
function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; }
|
||||||
|
canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); };
|
||||||
|
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selected.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selected.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 data = await response.json(); if (!data.ok) return message(data.error); state.overrides = data.overrides; staged = []; message("已保存,点击“保存并重新生成”生效"); };
|
||||||
|
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; setup(); message("已按保存的修改重新生成"); };
|
||||||
|
function focusDiagnostic(diagnostic) { state.focusedDiagnostic = diagnostic; const road = state.compiled.model.roads.find((item) => item.id === diagnostic.subjectId); if (road) { select(road); message(diagnostic.message); } else { draw(); message(diagnostic.message); } }
|
||||||
|
function showInIssueList(diagnostic) { return diagnostic.severity === "error" || diagnostic.rule !== "ordinary-junction-surface"; }
|
||||||
|
function setup() { areaLabel.textContent = state.areaId; diagnostics.innerHTML = ""; const issues = state.compiled.diagnostics.filter(showInIssueList); if (!issues.length) diagnostics.innerHTML = "<li>没有需要人工检查的问题。</li>"; for (const diagnostic of issues) { const item = document.createElement("li"); const button = document.createElement("button"); button.className = diagnostic.severity === "error" ? "error" : ""; button.textContent = diagnostic.message; button.onclick = () => focusDiagnostic(diagnostic); item.append(button); diagnostics.append(item); } setBounds(); resize(); select(null); }
|
||||||
|
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; setup(); message(`${state.compiled.model.roads.length} 条方向道路,${state.compiled.diagnostics.length} 个待检查项`); }).catch((error) => message(error.message));
|
||||||
|
window.onresize = resize;
|
||||||
4
scripts/workbench/index.html
Normal file
4
scripts/workbench/index.html
Normal file
@@ -0,0 +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="/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><p class="muted">点击问题可定位到道路或路口。</p><ul id="diagnostics"></ul></aside><section class="map"><canvas id="map"></canvas><div class="legend"><i class="reference"></i> osm2streets 参考面 <i class="native"></i> 自研道路面 <i class="line"></i> 道路中心线 <i class="junction"></i> 已识别路口 <i class="warning"></i> 待检查点</div></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">在地图中点击道路,查看和调整参数。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><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><details><summary>技术详情与来源</summary><pre id="evidence">无</pre></details></aside></main><script src="/app.js"></script></body></html>
|
||||||
Reference in New Issue
Block a user