From b5fa4482f054b5e409660e288164c23426c6a37e Mon Sep 17 00:00:00 2001 From: que01 Date: Thu, 13 Aug 2026 18:01:20 +0800 Subject: [PATCH] feat: add native road compiler workbench --- .../08-13-native-road-compiler/check.jsonl | 3 + .../08-13-native-road-compiler/design.md | 100 ++++++++ .../implement.jsonl | 3 + .../08-13-native-road-compiler/implement.md | 32 +++ .../tasks/08-13-native-road-compiler/prd.md | 93 +++++++ .../08-13-native-road-compiler/task.json | 26 ++ README.md | 24 ++ package.json | 3 + scripts/compile-native-roads.js | 71 ++++++ scripts/lib/area-config.js | 3 + scripts/lib/native-road.js | 229 ++++++++++++++++++ scripts/road-workbench.js | 58 +++++ scripts/test-native-road.js | 28 +++ scripts/workbench/app.css | 1 + scripts/workbench/app.js | 116 +++++++++ scripts/workbench/index.html | 4 + 16 files changed, 794 insertions(+) create mode 100644 .trellis/tasks/08-13-native-road-compiler/check.jsonl create mode 100644 .trellis/tasks/08-13-native-road-compiler/design.md create mode 100644 .trellis/tasks/08-13-native-road-compiler/implement.jsonl create mode 100644 .trellis/tasks/08-13-native-road-compiler/implement.md create mode 100644 .trellis/tasks/08-13-native-road-compiler/prd.md create mode 100644 .trellis/tasks/08-13-native-road-compiler/task.json create mode 100644 scripts/compile-native-roads.js create mode 100644 scripts/lib/native-road.js create mode 100644 scripts/road-workbench.js create mode 100644 scripts/test-native-road.js create mode 100644 scripts/workbench/app.css create mode 100644 scripts/workbench/app.js create mode 100644 scripts/workbench/index.html diff --git a/.trellis/tasks/08-13-native-road-compiler/check.jsonl b/.trellis/tasks/08-13-native-road-compiler/check.jsonl new file mode 100644 index 0000000..d974d03 --- /dev/null +++ b/.trellis/tasks/08-13-native-road-compiler/check.jsonl @@ -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."} diff --git a/.trellis/tasks/08-13-native-road-compiler/design.md b/.trellis/tasks/08-13-native-road-compiler/design.md new file mode 100644 index 0000000..8cc797d --- /dev/null +++ b/.trellis/tasks/08-13-native-road-compiler/design.md @@ -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 ` 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 ` 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:`. + +### Override file + +`/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 + +`/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. diff --git a/.trellis/tasks/08-13-native-road-compiler/implement.jsonl b/.trellis/tasks/08-13-native-road-compiler/implement.jsonl new file mode 100644 index 0000000..0018279 --- /dev/null +++ b/.trellis/tasks/08-13-native-road-compiler/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/08-13-native-road-compiler/implement.md b/.trellis/tasks/08-13-native-road-compiler/implement.md new file mode 100644 index 0000000..6664b0e --- /dev/null +++ b/.trellis/tasks/08-13-native-road-compiler/implement.md @@ -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. diff --git a/.trellis/tasks/08-13-native-road-compiler/prd.md b/.trellis/tasks/08-13-native-road-compiler/prd.md new file mode 100644 index 0000000..5dfde92 --- /dev/null +++ b/.trellis/tasks/08-13-native-road-compiler/prd.md @@ -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. diff --git a/.trellis/tasks/08-13-native-road-compiler/task.json b/.trellis/tasks/08-13-native-road-compiler/task.json new file mode 100644 index 0000000..32fd18f --- /dev/null +++ b/.trellis/tasks/08-13-native-road-compiler/task.json @@ -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": {} +} \ No newline at end of file diff --git a/README.md b/README.md index 2df9127..d0f638c 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,30 @@ npm run preflight:area -- --config config/areas/nantaizi-lake-innovation-valley. 预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次 验证的 config / OSM 文件摘要和检查结果。 +## Native Road Workbench + +`road:compile` 是独立于 osm2streets 的实验性道路编译器入口。它从 OSM 生成可追溯的 +道路模型、基础道路面、诊断与对比摘要,写入 `outputs//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//native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和 +启动工作台时会自动加载。编辑不会直接修改最终 polygon。 + +道路按行驶方向显示:点击道路的任意线段后,右侧“路口连接”只列出该方向到达终点路口后 +可驶入的目标道路,并标记为左转、直行、右转或掉头;每一个目标道路只出现一次。 + 诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、 现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。 缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`。 diff --git a/package.json b/package.json index b273465..a4dda5e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "compress:glb": "node scripts/compress-glb.js", "diagnose:area": "node scripts/diagnose-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:build-stages": "node scripts/test-build-stages.js", "test:budgets": "node scripts/test-asset-budgets.js", diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js new file mode 100644 index 0000000..feb8516 --- /dev/null +++ b/scripts/compile-native-roads.js @@ -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 }; diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index 6d4c133..9394e72 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -32,9 +32,12 @@ function normalizeAreaConfig(raw, options = {}) { const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime")); const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview")); 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 = { areaDir, geojsonDir, + nativeRoadDir, + nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")), gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)), qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)), qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)), diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js new file mode 100644 index 0000000..4feb5c1 --- /dev/null +++ b/scripts/lib/native-road.js @@ -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(/]*?)(?:\/>|>([\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(/]*)>([\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(/]*)\/?\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(/]*)\/?\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 }; diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js new file mode 100644 index 0000000..30b7541 --- /dev/null +++ b/scripts/road-workbench.js @@ -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(); diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js new file mode 100644 index 0000000..f692e2b --- /dev/null +++ b/scripts/test-native-road.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("assert"); +const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road"); + +const 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"); diff --git a/scripts/workbench/app.css b/scripts/workbench/app.css new file mode 100644 index 0000000..64a47e3 --- /dev/null +++ b/scripts/workbench/app.css @@ -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}} diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js new file mode 100644 index 0000000..229b2d4 --- /dev/null +++ b/scripts/workbench/app.js @@ -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 = "
  • 没有需要人工检查的问题。
  • "; 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; diff --git a/scripts/workbench/index.html b/scripts/workbench/index.html new file mode 100644 index 0000000..587102e --- /dev/null +++ b/scripts/workbench/index.html @@ -0,0 +1,4 @@ + +道路编译工作台 +
    道路编译工作台
    +
    osm2streets 参考面 自研道路面 道路中心线 已识别路口 待检查点