From 73707dabbe70be3c1ab8926471563f1a6c47f16e Mon Sep 17 00:00:00 2001 From: que01 Date: Tue, 25 Aug 2026 17:12:58 +0800 Subject: [PATCH] refactor: move road compiler core into package --- .../08-25-rc-p1-package-boundary/design.md | 53 + .../08-25-rc-p1-package-boundary/implement.md | 6 +- .../tasks/08-25-rc-p1-package-boundary/prd.md | 4 +- .../research/traffic-signals-split-matrix.md | 21 + .../08-25-rc-p1-package-boundary/task.json | 2 +- config/areas/fengshu-er-road.json | 2 +- package.json | 3 + packages/road-compiler/package.json | 10 + .../road-compiler/src/compile/compiler.js | 141 + .../src/compile}/complex-junction.js | 2 +- .../road-compiler/src/compile}/native-road.js | 0 .../src/compile}/turn-lane-arrows.js | 4 +- .../src/geometry}/lane-geometry.js | 0 packages/road-compiler/src/index.js | 13 + .../src}/native-traffic-signals.js | 0 .../lib => packages/road-compiler/src}/osm.js | 0 .../road-compiler/src/reference/gaode.js | 0 packages/road-compiler/src/traffic-signals.js | 330 + .../test/fixtures/fengshu-er-road.osm | 11091 ++++++++++++++++ packages/road-compiler/test/index.js | 18 + scripts/build-osm2streets-qgis.js | 2 +- scripts/compile-native-roads.js | 114 +- scripts/inspect-junction-reference.js | 2 +- scripts/lib/area-config.js | 27 +- scripts/lib/traffic-signals.js | 318 +- scripts/lib/vehicle-route.js | 4 +- scripts/reimport-gpkg.js | 2 +- scripts/render-turn-lane-arrow-samples.js | 2 +- scripts/road-workbench.js | 6 +- scripts/test-gaode-junction-reference.js | 2 +- scripts/test-native-road.js | 2 +- scripts/test-preview-assets.js | 4 +- scripts/test-traffic-signals.js | 4 +- scripts/test-turn-lane-arrows.js | 2 +- scripts/test-v2x-cesium-overlay.js | 2 +- 35 files changed, 11739 insertions(+), 454 deletions(-) create mode 100644 .trellis/tasks/08-25-rc-p1-package-boundary/design.md create mode 100644 .trellis/tasks/08-25-rc-p1-package-boundary/research/traffic-signals-split-matrix.md create mode 100644 packages/road-compiler/package.json create mode 100644 packages/road-compiler/src/compile/compiler.js rename {scripts/lib => packages/road-compiler/src/compile}/complex-junction.js (99%) rename {scripts/lib => packages/road-compiler/src/compile}/native-road.js (100%) rename {scripts/lib => packages/road-compiler/src/compile}/turn-lane-arrows.js (99%) rename {scripts/lib => packages/road-compiler/src/geometry}/lane-geometry.js (100%) create mode 100644 packages/road-compiler/src/index.js rename {scripts/lib => packages/road-compiler/src}/native-traffic-signals.js (100%) rename {scripts/lib => packages/road-compiler/src}/osm.js (100%) rename scripts/lib/gaode-junction-reference.js => packages/road-compiler/src/reference/gaode.js (100%) create mode 100644 packages/road-compiler/src/traffic-signals.js create mode 100644 packages/road-compiler/test/fixtures/fengshu-er-road.osm create mode 100644 packages/road-compiler/test/index.js diff --git a/.trellis/tasks/08-25-rc-p1-package-boundary/design.md b/.trellis/tasks/08-25-rc-p1-package-boundary/design.md new file mode 100644 index 0000000..bd7448e --- /dev/null +++ b/.trellis/tasks/08-25-rc-p1-package-boundary/design.md @@ -0,0 +1,53 @@ +# Phase 1 Technical Design + +## Boundary + +`packages/road-compiler/` becomes the single implementation owner for native +road compilation. It is a CommonJS workspace package with explicit public +entrypoints for compiler APIs, traffic-signal schema, turn-lane helpers, OSM +parsing, reference conversion, and CLIs. It never imports `area-config`, reads +`config/areas`, or derives host paths. + +The host owns `scripts/lib/area-config.js` and maps normalized configuration to +the v1 `RoadCompilerInput` shape using `toRoadCompilerInput(area)`. During P1, +`build-area.js` calls the package in-process; P2 is the only phase that changes +that call to a child process. + +## Source Ownership and Compatibility + +The following current `scripts/lib` implementations move into package `src`: +`native-road`, `complex-junction`, `turn-lane-arrows`, `lane-geometry`, +`gaode-junction-reference`, `native-traffic-signals`, and `osm`. + +Some moved modules also serve legacy host paths: QGIS uses turn-lane arrows, +vehicle preview and reimport use OSM parsing, and preview tests use lane +geometry. Those consumers import the package entrypoints after the move. This +preserves one implementation while allowing host -> package dependency. + +Traffic signal ownership follows the matrix in +`research/traffic-signals-split-matrix.md`: schema, geometry, validation, and +runtime construction move to the package. The host retains only two file-I/O +adapters, `readTrafficSignalFeatures()` and `readTrafficSignals()`, both +delegating to package exports. No duplicated schema code remains. + +## Input and Path Handling + +The package compiler API accepts only `RoadCompilerInput` from +`docs/native-road-package-v1.md`. Package CLI commands accept an explicit JSON +input file (`--input`) and never load area configuration. The compatibility +host CLI still accepts `--config`, but immediately maps it through +`toRoadCompilerInput()` before entering the package. + +`junctionTemplates.*.referenceFile` changes to a path relative to its area +configuration. `readAreaConfig()` resolves it while normalizing configuration; +the package receives an absolute path and does no path resolution. + +## Tests and Rollout + +The package receives the required Fengshu OSM fixture plus migrated unit tests. +Its `npm test` must run from the package directory without reading host inputs +or outputs. After each source batch, run the two Phase 0 parity baselines. The +existing host command names remain compatibility wrappers through P1. + +Rollback is a normal Git revert of the latest batch commit. No module has a +parallel host copy after its batch lands, preventing divergence. diff --git a/.trellis/tasks/08-25-rc-p1-package-boundary/implement.md b/.trellis/tasks/08-25-rc-p1-package-boundary/implement.md index 4bff4ff..cba57e9 100644 --- a/.trellis/tasks/08-25-rc-p1-package-boundary/implement.md +++ b/.trellis/tasks/08-25-rc-p1-package-boundary/implement.md @@ -6,13 +6,13 @@ ```bash # 基线必须先绿 -for a in fengshu-er-road hanyang-block nantaizi-lake-innovation-valley; do +for a in fengshu-er-road nantaizi-lake-innovation-valley; do node scripts/road-parity.js --config config/areas/$a.json \ --compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a" done ``` -- [ ] 三区域基线全绿(否则回 Phase 0) +- [ ] 两个有效区域基线全绿(否则回 Phase 0) ## Step 1 — K1 矩阵(动代码之前) @@ -89,7 +89,7 @@ packages/road-compiler/ ```bash # parity -for a in fengshu-er-road hanyang-block nantaizi-lake-innovation-valley; do +for a in fengshu-er-road nantaizi-lake-innovation-valley; do node scripts/road-parity.js --config config/areas/$a.json \ --compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a" done diff --git a/.trellis/tasks/08-25-rc-p1-package-boundary/prd.md b/.trellis/tasks/08-25-rc-p1-package-boundary/prd.md index bad1b0d..ad3f372 100644 --- a/.trellis/tasks/08-25-rc-p1-package-boundary/prd.md +++ b/.trellis/tasks/08-25-rc-p1-package-boundary/prd.md @@ -66,9 +66,9 @@ ## Acceptance Criteria -- [ ] AC1.1 三区域 `road-parity --compare` 对 Phase 0 基线全绿 +- [ ] AC1.1 两个有效区域 `road-parity --compare` 对 Phase 0 基线全绿 - [ ] AC1.2 `npm run test:native-road`、`npm run test:road-workbench` 通过 -- [ ] AC1.3 `npm run build:area` 三区域完整跑通(含 blender / cesium 阶段) +- [ ] AC1.3 `npm run build:area` 枫树二路完整跑通(含 blender / cesium 阶段) - [ ] AC1.4 `grep -rn "area-config\|config/areas" packages/road-compiler/` 无命中 - [ ] AC1.5 `packages/road-compiler/` 内 `npm test` 不访问宿主 `inputs/` 或 `outputs/` - [ ] AC1.6 `scripts/lib/` 下无搬迁文件的残留副本 diff --git a/.trellis/tasks/08-25-rc-p1-package-boundary/research/traffic-signals-split-matrix.md b/.trellis/tasks/08-25-rc-p1-package-boundary/research/traffic-signals-split-matrix.md new file mode 100644 index 0000000..18d7bad --- /dev/null +++ b/.trellis/tasks/08-25-rc-p1-package-boundary/research/traffic-signals-split-matrix.md @@ -0,0 +1,21 @@ +# Traffic Signals Split Matrix + +Evidence gathered from `scripts/lib/traffic-signals.js` and repository-wide +call-site search on 2026-08-25. + +| Export | Current users | Owner after P1 | Migration | +|---|---|---|---| +| `SIGNAL_LAYOUT` | `buildTrafficSignalsFromFeatures` | compiler package | Export from `@osm-asset/road-compiler/traffic-signals` | +| `signalNodeKey` | `buildTrafficSignalsFromFeatures` | compiler package | No host caller today; preserve public export for runtime identity contract | +| `buildTrafficSignalFeatures` | `native-traffic-signals`, `readTrafficSignalFeatures` | compiler package | Host QGIS adapter imports it from the package | +| `validateTrafficSignalFeatures` | `native-traffic-signals`, signal tests | compiler package | Tests import package entrypoint | +| `validateTrafficSignalSourceReferences` | `native-traffic-signals`, `reimport-gpkg`, `readTrafficSignals` | compiler package | Reimport adapter imports package function | +| `buildTrafficSignalsFromFeatures` | `native-traffic-signals`, `readTrafficSignals`, `buildTrafficSignals` | compiler package | Host preview adapter imports package function | +| `buildTrafficSignals` | `test-preview-assets` | compiler package | Test imports package function; it is geometry/schema behavior, not host I/O | +| `readTrafficSignalFeatures` | `build-osm2streets-qgis` | host adapter | Keep a thin file-reading wrapper under `scripts/lib/traffic-signals.js`; it imports package functions | +| `readTrafficSignals` | `build-area` | host adapter | Keep a thin file-reading wrapper under `scripts/lib/traffic-signals.js`; it imports package functions | + +`scripts/lib/traffic-signals.js` must not retain a second implementation of +the schema or geometry. Its only P1 responsibility is host file I/O for the +legacy QGIS / preview paths. This preserves the one-way dependency: +host -> compiler package; the package never imports the host. diff --git a/.trellis/tasks/08-25-rc-p1-package-boundary/task.json b/.trellis/tasks/08-25-rc-p1-package-boundary/task.json index 4f11c94..687db1e 100644 --- a/.trellis/tasks/08-25-rc-p1-package-boundary/task.json +++ b/.trellis/tasks/08-25-rc-p1-package-boundary/task.json @@ -3,7 +3,7 @@ "name": "rc-p1-package-boundary", "title": "Phase 1:包边界(不换仓库)", "description": "在本仓库内建 packages/road-compiler,移入编译器模块,用窄输入契约替换 readAreaConfig 耦合,产物对基线逐字节一致", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/config/areas/fengshu-er-road.json b/config/areas/fengshu-er-road.json index 74148c6..e1382d2 100644 --- a/config/areas/fengshu-er-road.json +++ b/config/areas/fengshu-er-road.json @@ -33,7 +33,7 @@ { "id": "zhushanhu-fengshu-complex", "template": "complex-junction-v1", - "referenceFile": "/Users/que01/osm2streets-qgis-workflow/inputs/osm/珠山湖大道(枫树二路)口.geojson", + "referenceFile": "../../inputs/osm/珠山湖大道(枫树二路)口.geojson", "approachWidthMultiplier": 1.45, "approachLengthMeters": 32, "coreRadiusMeters": 28, diff --git a/package.json b/package.json index 465ca6f..075ec8a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.3.0", "private": true, "type": "commonjs", + "workspaces": [ + "packages/road-compiler" + ], "scripts": { "build": "node scripts/interactive-build.js", "build:area": "node scripts/build-area.js", diff --git a/packages/road-compiler/package.json b/packages/road-compiler/package.json new file mode 100644 index 0000000..336e583 --- /dev/null +++ b/packages/road-compiler/package.json @@ -0,0 +1,10 @@ +{ + "name": "@osm-asset/road-compiler", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "test": "node test/index.js" + } +} diff --git a/packages/road-compiler/src/compile/compiler.js b/packages/road-compiler/src/compile/compiler.js new file mode 100644 index 0000000..008f728 --- /dev/null +++ b/packages/road-compiler/src/compile/compiler.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./native-road"); +const { loadOrGenerate, runtime } = require("../native-traffic-signals"); + +function compileInput(input) { + validateInput(input); + const area = { + id: input.areaId, + input: input.osmFile, + nativeRoad: input.options, + outputs: { + nativeRoadOverrides: input.overridesFile, + nativeTrafficSignals: input.trafficSignalsFile, + nativeRoadDir: input.outDir, + pipelineDir: input.stagingDir, + geojsonDir: input.comparisonDir || null, + }, + }; + const overrides = loadOverrides(area.outputs.nativeRoadOverrides); + const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); + // Editing the source OSM retires the ids some overrides point at. Those + // entries can no longer match anything, so drop them with a diagnostic rather + // than aborting the whole compile — otherwise every OSM edit blocks the + // pipeline until the file is hand-pruned, one error message at a time. + const validated = validateOverrides(overrides, model, { skipStaleTargets: true }); + for (const item of validated.stale) console.warn(`[warning] 忽略失效的 override(目标已不存在):${item.id} -> ${item.target}`); + fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); + const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates }); + compiled.diagnostics.push(...validated.stale.map((item) => ({ + id: `diagnostic:stale-override:${item.id}`, + severity: "warning", + subjectId: item.id, + sourceIds: [], + rule: "stale-override-target", + message: `该设置指向的 ${item.kind} 目标 ${item.target} 已不存在(OSM 改动后 id 失效),本次编译已忽略。可在工作台重新设置,或从 native-road-overrides.json 中删除。`, + geometry: null, + }))); + const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface); + const signalRuntime = runtime(signalDocument); + // Persist validation normalization, including one-time legacy heading migration. + writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument); + 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, trafficSignals: area.outputs.nativeTrafficSignals }, + model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections }, + movements: compiled.movements, + trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length }, + diagnostics: compiled.diagnostics, + layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" }, + }; + const comparison = compareOsm2Streets(area, result.model, compiled); + writeJsonAtomic(path.join(staging, "compiled.json"), result); + 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, "traffic-signal-assemblies.json"), signalDocument.assemblies); + writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime); + writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface); + writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines); + writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface); + writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface); + writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines); + writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators); + writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines); + writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows); + writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows); + writeJsonAtomic(path.join(staging, "layers", "crosswalks.geojson"), compiled.crosswalks); + writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines); + writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors); + 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, model, compiled) { + const source = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null; + let featureCount = null; + if (source && fs.existsSync(source)) { + const collection = JSON.parse(fs.readFileSync(source, "utf8")); + featureCount = Array.isArray(collection.features) ? collection.features.length : null; + } + const diagnosticsBySeverity = {}; + const diagnosticsByRule = {}; + for (const item of compiled.diagnostics) { + diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1; + diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1; + } + const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end"); + const junctions = compiled.intersectionSurface.features; + const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback"); + return { + schema: "native-road-comparison/v2", + nativeRoadCount: model.roads.length, + nativeRoadSurfaceFeatures: compiled.roadSurface.features.length, + nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length, + nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length, + nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length, + nativeFallbackJunctions: fallbackJunctions.length, + nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0), + nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length, + nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length, + nativeCenterLineFeatures: compiled.centerLines.features.length, + nativeDirectionArrowFeatures: compiled.directionArrows.features.length, + nativeTurnArrowFeatures: compiled.turnArrows.features.length, + nativeCrosswalkFeatures: compiled.crosswalks.features.length, + nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length, + nativeConnectorFeatures: compiled.connectors.features.length, + nativeMovementCount: compiled.movements.length, + nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length, + nativeConnectionCount: model.connections.length, + unconnectedInteriorRoadEnds: dangling.length, + unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length, + diagnosticsBySeverity, + diagnosticsByRule, + osm2streetsRoadSurfaceFeatures: featureCount, + osm2streetsAvailable: featureCount !== null, + note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.", + }; +} + +function validateInput(input) { + if (!input || typeof input !== "object") throw new Error("RoadCompilerInput must be an object"); + for (const key of ["areaId", "osmFile", "outDir", "stagingDir", "overridesFile", "trafficSignalsFile"]) { + if (typeof input[key] !== "string" || input[key].trim() === "") throw new Error(`RoadCompilerInput.${key} must be a non-empty string`); + } + if (!input.options || typeof input.options !== "object") throw new Error("RoadCompilerInput.options must be an object"); + if (typeof input.options.edgeLines !== "boolean") throw new Error("RoadCompilerInput.options.edgeLines must be a boolean"); + if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== "object") throw new Error("RoadCompilerInput.options.junctionTemplates must be an object"); +} + +module.exports = { compileInput, validateInput }; diff --git a/scripts/lib/complex-junction.js b/packages/road-compiler/src/compile/complex-junction.js similarity index 99% rename from scripts/lib/complex-junction.js rename to packages/road-compiler/src/compile/complex-junction.js index b346c02..5f41891 100644 --- a/scripts/lib/complex-junction.js +++ b/packages/road-compiler/src/compile/complex-junction.js @@ -1,7 +1,7 @@ "use strict"; const fs = require("fs"); -const { convertGeoJson, boundsOf } = require("./gaode-junction-reference"); +const { convertGeoJson, boundsOf } = require("../reference/gaode"); const metricsCache = new WeakMap(); const CORNER_FILLET_SEGMENTS = 12; // Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band diff --git a/scripts/lib/native-road.js b/packages/road-compiler/src/compile/native-road.js similarity index 100% rename from scripts/lib/native-road.js rename to packages/road-compiler/src/compile/native-road.js diff --git a/scripts/lib/turn-lane-arrows.js b/packages/road-compiler/src/compile/turn-lane-arrows.js similarity index 99% rename from scripts/lib/turn-lane-arrows.js rename to packages/road-compiler/src/compile/turn-lane-arrows.js index 5a7f8ee..4230679 100644 --- a/scripts/lib/turn-lane-arrows.js +++ b/packages/road-compiler/src/compile/turn-lane-arrows.js @@ -2,9 +2,9 @@ const fs = require("fs"); const path = require("path"); -const { laneCenterline } = require("./lane-geometry"); +const { laneCenterline } = require("../geometry/lane-geometry"); -const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "assets", "lane-icons", "manifest.json"); +const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "..", "..", "assets", "lane-icons", "manifest.json"); const LANE_WIDTH_METERS = 3.2; const PLACEMENT_DISTANCE_METERS = 9; const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18; diff --git a/scripts/lib/lane-geometry.js b/packages/road-compiler/src/geometry/lane-geometry.js similarity index 100% rename from scripts/lib/lane-geometry.js rename to packages/road-compiler/src/geometry/lane-geometry.js diff --git a/packages/road-compiler/src/index.js b/packages/road-compiler/src/index.js new file mode 100644 index 0000000..a435240 --- /dev/null +++ b/packages/road-compiler/src/index.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + laneGeometry: require("./geometry/lane-geometry"), + gaodeReference: require("./reference/gaode"), + turnLaneArrows: require("./compile/turn-lane-arrows"), + complexJunction: require("./compile/complex-junction"), + osm: require("./osm"), + trafficSignals: require("./traffic-signals"), + nativeTrafficSignals: require("./native-traffic-signals"), + nativeRoad: require("./compile/native-road"), + compiler: require("./compile/compiler"), +}; diff --git a/scripts/lib/native-traffic-signals.js b/packages/road-compiler/src/native-traffic-signals.js similarity index 100% rename from scripts/lib/native-traffic-signals.js rename to packages/road-compiler/src/native-traffic-signals.js diff --git a/scripts/lib/osm.js b/packages/road-compiler/src/osm.js similarity index 100% rename from scripts/lib/osm.js rename to packages/road-compiler/src/osm.js diff --git a/scripts/lib/gaode-junction-reference.js b/packages/road-compiler/src/reference/gaode.js similarity index 100% rename from scripts/lib/gaode-junction-reference.js rename to packages/road-compiler/src/reference/gaode.js diff --git a/packages/road-compiler/src/traffic-signals.js b/packages/road-compiler/src/traffic-signals.js new file mode 100644 index 0000000..e3f8d11 --- /dev/null +++ b/packages/road-compiler/src/traffic-signals.js @@ -0,0 +1,330 @@ +"use strict"; + +const fs = require("fs"); +const crypto = require("crypto"); +const { parseOsm } = require("./osm"); + +const EARTH_RADIUS = 6371008.8; +const CURB_OFFSET_METERS = 5.2; +const MAST_REACH_METERS = 4.5; +const SIGNAL_LAYOUT = Object.freeze({ + poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21, + mastHeightMeters: 6.25, headCenterHeightMeters: 6.25, + headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62, + lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18, + lensVerticalOffsetsMeters: [0.49, -0.01, -0.51], + countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05, + countdownWidthMeters: 0.82, countdownDepthMeters: 0.14, + countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0, +}); + +function buildTrafficSignalFeatures(stopLines, intersections, controls = []) { + const centers = (intersections.features || []).map((feature, index) => { + const point = polygonCenter(feature.geometry); + return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) }; + }).filter((entry) => entry.point); + const clusteredStops = new Map(); + for (const feature of stopLines.features || []) { + const clusterId = feature.properties?.cluster_id; + const point = polygonCenter(feature.geometry); + if (!clusterId || !point) continue; + if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []); + clusteredStops.get(clusterId).push(point); + } + for (const [clusterId, points] of clusteredStops) { + if (points.length < 3) continue; + const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]); + centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) }); + } + const candidates = []; + for (const feature of stopLines.features || []) { + const center = polygonCenter(feature.geometry); + if (!center) continue; + const clusterId = feature.properties?.cluster_id; + const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers); + if (!intersection || metersBetween(center, intersection.point) > 32) continue; + const axis = roadAxis(feature.geometry, center, intersection.point); + if (!axis) continue; + const right = [axis[1], -axis[0]]; + candidates.push({ + intersectionId: intersection.id, center, axis, + point: intersection.clusterId + ? moveMeters(center, right, CURB_OFFSET_METERS) + : moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS), + headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), + matchHeadingDegrees: intersection.clusterId + ? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI) + : null, + }); + } + const features = []; + for (const control of controls) { + const controlPoint = [Number(control.longitude), Number(control.latitude)]; + if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue; + const intersection = nearestCenter(controlPoint, centers); + if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue; + const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms); + const groups = phaseGroups(arms); + arms.forEach((candidate, index) => { + const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`; + const sourceWayId = String(candidate.osmArm?.wayId || "legacy"); + const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId); + const approachId = `${sourceWayId}:${neighborNodeId}`; + const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`; + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: candidate.point.slice() }, + properties: { + signal_uid: signalUid, display_id: signalUid, control_id: String(control.id), + approach_id: approachId, source_way_id: sourceWayId, + // These are independent assembly controls. heading_deg remains a + // migration hint for older native documents only. + mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90), + face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180), + phase_group: groups[index], + mast_reach_m: MAST_REACH_METERS, + stop_lon: candidate.center[0], stop_lat: candidate.center[1], + enabled: true, z_offset_m: 0, + }, + }); + }); + } + return validateTrafficSignalFeatures({ type: "FeatureCollection", features }); +} + +function validateTrafficSignalFeatures(collection) { + if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) { + throw new Error("Traffic signal assemblies must be a FeatureCollection"); + } + const uids = new Set(); + const displayIds = new Set(); + const features = collection.features.map((feature, index) => { + const label = `traffic signal feature ${index + 1}`; + if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) || + feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) { + throw new Error(`${label}: geometry must be a finite Point`); + } + const input = feature.properties || {}; + const text = (key, required = true) => { + const value = input[key] == null ? "" : String(input[key]).trim(); + if (required && !value) throw new Error(`${label}: missing ${key}`); + return value; + }; + const number = (key, options = {}) => { + if (input[key] === null || input[key] === undefined || input[key] === "") { + throw new Error(`${label}: missing ${key}`); + } + const value = Number(input[key]); + if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) { + throw new Error(`${label}: invalid ${key} '${input[key]}'`); + } + return value; + }; + const signalUid = text("signal_uid"); + if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`); + if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`); + uids.add(signalUid); + const displayId = text("display_id", false); + if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`); + if (displayId) displayIds.add(displayId); + const phaseGroup = number("phase_group", { min: 0, max: 1 }); + if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`); + const enabled = normalizeBoolean(input.enabled, label); + const controlId = text("control_id"); + const approachId = text("approach_id"); + const sourceWayId = text("source_way_id"); + if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`); + const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`; + if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`); + const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg")); + if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) { + throw new Error(`${label}: missing mast_heading_deg`); + } + if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) { + throw new Error(`${label}: missing face_heading_deg`); + } + const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === "" + ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90) + : normalizeDegrees(number("mast_heading_deg")); + const faceHeading = input.face_heading_deg == null || input.face_heading_deg === "" + ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180) + : normalizeDegrees(number("face_heading_deg")); + return { + type: "Feature", + geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) }, + properties: { + ...input, signal_uid: signalUid, display_id: displayId, + control_id: controlId, approach_id: approachId, + source_way_id: sourceWayId, + // Retain the legacy value only for migration compatibility. Runtime + // geometry is entirely defined by mast_heading_deg and face_heading_deg. + heading_deg: legacyHeading, + mast_heading_deg: mastHeading, face_heading_deg: faceHeading, + phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }), + stop_lon: number("stop_lon", { min: -180, max: 180 }), + stop_lat: number("stop_lat", { min: -90, max: 90 }), + enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }), + }, + }; + }); + return { type: "FeatureCollection", features }; +} + +function buildTrafficSignalsFromFeatures(collection) { + const normalized = validateTrafficSignalFeatures(collection); + const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => { + const p = feature.properties; + const point = feature.geometry.coordinates; + const mastAxis = headingVector(p.mast_heading_deg); + return { + id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id, + nodeKey: signalNodeKey(p.signal_uid), + controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id, + phaseGroup: p.phase_group, longitude: point[0], latitude: point[1], + stopLongitude: p.stop_lon, stopLatitude: p.stop_lat, + // Existing Blender readers require headingDegrees. It is a compatibility + // alias only; the independent mast/face fields below define all geometry. + headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg, + mastHeadingDegrees: p.mast_heading_deg, + faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m, + zOffsetMeters: p.z_offset_m, + pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m), + }; + }); + return { version: 3, layout: SIGNAL_LAYOUT, signals }; +} + +function signalNodeKey(signalUid) { + return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`; +} + +function reconcileTrafficSignalSourceReferences(collection, controls) { + const normalized = validateTrafficSignalFeatures(collection); + const approachesByControl = new Map((controls || []).map((control) => [ + String(control.id), + new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)), + ])); + const kept = []; + const dropped = []; + for (const [index, feature] of normalized.features.entries()) { + const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties; + const approaches = approachesByControl.get(controlId); + if (!approaches) { + dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` }); + continue; + } + if (!approaches.has(approachId)) { + dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` }); + continue; + } + kept.push(feature); + } + return { collection: { ...normalized, features: kept }, dropped }; +} + +function validateTrafficSignalSourceReferences(collection, controls) { + const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls); + if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`); + return reconciled; +} + +function buildTrafficSignals(stopLines, intersections, controls = []) { + return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls)); +} + +function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) { + const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls; + return buildTrafficSignalFeatures( + JSON.parse(fs.readFileSync(stopLinePath, "utf8")), + JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls, + ); +} + +function readTrafficSignals(editablePath, osmPath = null) { + const collection = JSON.parse(fs.readFileSync(editablePath, "utf8")); + if (osmPath) { + const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls; + validateTrafficSignalSourceReferences(collection, controls); + } + return buildTrafficSignalsFromFeatures(collection); +} + +function normalizeBoolean(value, label) { + if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true; + if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false; + throw new Error(`${label}: invalid enabled '${value}'`); +} + +function uniqueApproachArms(candidates, controlPoint) { + const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) })) + .sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance); + const arms = []; + for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate); + return arms; +} + +function matchOsmArms(candidates, controlPoint, osmArms) { + const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) })); + if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint); + return osmArms.map((osmArm) => { + let bestIndex = -1; let bestDistance = Infinity; + remaining.forEach((item, index) => { + const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees); + const distance = item.matchHeadingDegrees == null + ? directedDistance + : Math.min( + angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees), + angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees), + ); + if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } + }); + const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm); + return { ...candidate, osmArm }; + }); +} + +function fallbackCandidate(controlPoint, osmArm) { + const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]]; + const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2); + return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true }; +} + +function phaseGroups(arms) { + const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups; + let main = [0, 1]; let best = -1; + for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } } + groups[main[0]] = 0; groups[main[1]] = 0; return groups; +} + +function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) { + const face = headingVector(faceHeadingDegrees); + const head = moveMeters(pole, mastAxis, mastReach); + const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset }); + const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters); + const faceRight = [-face[1], face[0]]; + const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters); + return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } }; +} + +function polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; } +function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; } +function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; } +function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; } +function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; } +function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; } +function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; } +function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; } +function normalizeDegrees(value) { return ((value % 360) + 360) % 360; } +function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); } + +module.exports = { + SIGNAL_LAYOUT, + signalNodeKey, + buildTrafficSignalFeatures, + validateTrafficSignalFeatures, + validateTrafficSignalSourceReferences, + buildTrafficSignalsFromFeatures, + buildTrafficSignals, + readTrafficSignalFeatures, + readTrafficSignals, +}; diff --git a/packages/road-compiler/test/fixtures/fengshu-er-road.osm b/packages/road-compiler/test/fixtures/fengshu-er-road.osm new file mode 100644 index 0000000..300a4af --- /dev/null +++ b/packages/road-compiler/test/fixtures/fengshu-er-road.osm @@ -0,0 +1,11091 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/road-compiler/test/index.js b/packages/road-compiler/test/index.js new file mode 100644 index 0000000..23fb241 --- /dev/null +++ b/packages/road-compiler/test/index.js @@ -0,0 +1,18 @@ +"use strict"; + +const assert = require("assert/strict"); +const fs = require("fs"); +const path = require("path"); +const compiler = require("../src"); + +assert.equal(typeof compiler.laneGeometry.laneCenterline, "function"); +assert.equal(typeof compiler.gaodeReference.convertGeoJson, "function"); +assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, "function"); +assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, "function"); +assert.equal(typeof compiler.nativeRoad.compileRoadModel, "function"); +const fixture = path.join(__dirname, "fixtures", "fengshu-er-road.osm"); +assert.ok(fs.existsSync(fixture)); +assert.throws(() => compiler.compiler.validateInput({ id: "area" }), /RoadCompilerInput/); +const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, "utf8"), { schema: "native-road-overrides/v1", overrides: [] }); +assert.equal(model.roads.length, 24); +console.log("road compiler package tests passed"); diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index f7e3cb7..b265a09 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -6,7 +6,7 @@ const os = require("os"); const { execFileSync } = require("child_process"); const { JsStreetNetwork } = require("osm2streets-js-node"); const { qgisPaths } = require("./lib/tool-paths"); -const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows"); +const { buildCustomTurnLaneArrows } = require("../packages/road-compiler/src/compile/turn-lane-arrows"); const { readTrafficSignalFeatures } = require("./lib/traffic-signals"); const { SCENE_LAYERS, diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js index d09e6d2..60c9739 100644 --- a/scripts/compile-native-roads.js +++ b/scripts/compile-native-roads.js @@ -1,11 +1,9 @@ #!/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 { loadOrGenerate, runtime } = require("./lib/native-traffic-signals"); +const { readAreaConfig, toRoadCompilerInput } = require("./lib/area-config"); +const { compileInput } = require("../packages/road-compiler/src/compile/compiler"); const repoRoot = path.resolve(__dirname, ".."); @@ -21,112 +19,8 @@ function parseArgs(argv) { function compileArea(configPath) { const area = readAreaConfig(configPath, { repoRoot }); - const overrides = loadOverrides(area.outputs.nativeRoadOverrides); - const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); - // Editing the source OSM retires the ids some overrides point at. Those - // entries can no longer match anything, so drop them with a diagnostic rather - // than aborting the whole compile — otherwise every OSM edit blocks the - // pipeline until the file is hand-pruned, one error message at a time. - const validated = validateOverrides(overrides, model, { skipStaleTargets: true }); - for (const item of validated.stale) console.warn(`[warning] 忽略失效的 override(目标已不存在):${item.id} -> ${item.target}`); - fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); - const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates }); - compiled.diagnostics.push(...validated.stale.map((item) => ({ - id: `diagnostic:stale-override:${item.id}`, - severity: "warning", - subjectId: item.id, - sourceIds: [], - rule: "stale-override-target", - message: `该设置指向的 ${item.kind} 目标 ${item.target} 已不存在(OSM 改动后 id 失效),本次编译已忽略。可在工作台重新设置,或从 native-road-overrides.json 中删除。`, - geometry: null, - }))); - const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface); - const signalRuntime = runtime(signalDocument); - // Persist validation normalization, including one-time legacy heading migration. - writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument); - 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, trafficSignals: area.outputs.nativeTrafficSignals }, - model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections }, - movements: compiled.movements, - trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length }, - diagnostics: compiled.diagnostics, - layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" }, - }; - const comparison = compareOsm2Streets(area, result.model, compiled); - writeJsonAtomic(path.join(staging, "compiled.json"), result); - 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, "traffic-signal-assemblies.json"), signalDocument.assemblies); - writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime); - writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface); - writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines); - writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface); - writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface); - writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines); - writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators); - writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines); - writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows); - writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows); - writeJsonAtomic(path.join(staging, "layers", "crosswalks.geojson"), compiled.crosswalks); - writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines); - writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors); - 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, model, compiled) { - 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; - } - const diagnosticsBySeverity = {}; - const diagnosticsByRule = {}; - for (const item of compiled.diagnostics) { - diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1; - diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1; - } - const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end"); - const junctions = compiled.intersectionSurface.features; - const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback"); - return { - schema: "native-road-comparison/v2", - nativeRoadCount: model.roads.length, - nativeRoadSurfaceFeatures: compiled.roadSurface.features.length, - nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length, - nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length, - nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length, - nativeFallbackJunctions: fallbackJunctions.length, - nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0), - nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length, - nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length, - nativeCenterLineFeatures: compiled.centerLines.features.length, - nativeDirectionArrowFeatures: compiled.directionArrows.features.length, - nativeTurnArrowFeatures: compiled.turnArrows.features.length, - nativeCrosswalkFeatures: compiled.crosswalks.features.length, - nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length, - nativeConnectorFeatures: compiled.connectors.features.length, - nativeMovementCount: compiled.movements.length, - nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length, - nativeConnectionCount: model.connections.length, - unconnectedInteriorRoadEnds: dangling.length, - unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length, - diagnosticsBySeverity, - diagnosticsByRule, - osm2streetsRoadSurfaceFeatures: featureCount, - osm2streetsAvailable: featureCount !== null, - note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.", - }; + const compiled = compileInput(toRoadCompilerInput(area)); + return { ...compiled, area }; } function main() { diff --git a/scripts/inspect-junction-reference.js b/scripts/inspect-junction-reference.js index 0dd8f55..8ba6e2a 100644 --- a/scripts/inspect-junction-reference.js +++ b/scripts/inspect-junction-reference.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); -const { inspectReference, localReferenceSvg } = require("./lib/gaode-junction-reference"); +const { inspectReference, localReferenceSvg } = require("../packages/road-compiler/src/reference/gaode"); function parseArgs(argv) { const result = {}; diff --git a/scripts/lib/area-config.js b/scripts/lib/area-config.js index d5b88b3..4eee8db 100644 --- a/scripts/lib/area-config.js +++ b/scripts/lib/area-config.js @@ -8,7 +8,7 @@ function readAreaConfig(file, options = {}) { if (!fs.existsSync(file)) { throw new Error(`Config file not found: ${file}`); } - return normalizeAreaConfig(JSON.parse(fs.readFileSync(file, "utf8")), options); + return normalizeAreaConfig(JSON.parse(fs.readFileSync(file, "utf8")), { ...options, configDir: path.dirname(path.resolve(file)) }); } function normalizeAreaConfig(raw, options = {}) { @@ -113,7 +113,7 @@ function normalizeAreaConfig(raw, options = {}) { }, nativeRoad: { edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"), - junctionTemplates: normalizeJunctionTemplates(raw.nativeRoad?.junctionTemplates, repoRoot), + junctionTemplates: normalizeJunctionTemplates(raw.nativeRoad?.junctionTemplates, repoRoot, options.configDir || repoRoot), }, osm2streets: raw.osm2streets || { debug_each_step: false, @@ -154,7 +154,7 @@ function normalizeV2xPreviewConfig(raw) { }; } -function normalizeJunctionTemplates(raw, repoRoot) { +function normalizeJunctionTemplates(raw, repoRoot, configDir) { if (raw === undefined || raw === null) return { enabled: false, references: [] }; if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("nativeRoad.junctionTemplates must be an object"); const enabled = booleanOption(raw.enabled, false, "nativeRoad.junctionTemplates.enabled"); @@ -164,7 +164,7 @@ function normalizeJunctionTemplates(raw, repoRoot) { const nodeId = requireText(item.nodeId, `nativeRoad.junctionTemplates.references[${index}].nodeId`); const template = item.template ?? "cross-v1"; if (template !== "cross-v1") throw new Error(`nativeRoad.junctionTemplates.references[${index}].template must be \"cross-v1\"`); - const referenceFile = item.referenceFile ? path.resolve(item.referenceFile) : null; + const referenceFile = item.referenceFile ? path.resolve(configDir, item.referenceFile) : null; const coordinateSystem = item.coordinateSystem ?? "GCJ-02"; if (coordinateSystem !== "GCJ-02") throw new Error(`nativeRoad.junctionTemplates.references[${index}].coordinateSystem must be \"GCJ-02\"`); const cornerRadiusMultiplier = numberOption(item.cornerRadiusMultiplier, 1, `nativeRoad.junctionTemplates.references[${index}].cornerRadiusMultiplier`, 0.75, 1.25); @@ -181,7 +181,7 @@ function normalizeJunctionTemplates(raw, repoRoot) { if (!Array.isArray(nodeIds) || nodeIds.length < 2 || nodeIds.some((value) => typeof value !== "string" && typeof value !== "number")) throw new Error(`nativeRoad.junctionTemplates.clusters[${index}].nodeIds must contain at least two node ids`); const template = item.template ?? "complex-junction-v1"; if (template !== "cross-cluster-v1" && template !== "complex-junction-v1") throw new Error(`nativeRoad.junctionTemplates.clusters[${index}].template must be \"cross-cluster-v1\" or \"complex-junction-v1\"`); - const referenceFile = item.referenceFile ? path.resolve(item.referenceFile) : null; + const referenceFile = item.referenceFile ? path.resolve(configDir, item.referenceFile) : null; const approachWidthMultiplier = numberOption(item.approachWidthMultiplier, 1.45, `nativeRoad.junctionTemplates.clusters[${index}].approachWidthMultiplier`, 1, 1.8); const approachLengthMeters = numberOption(item.approachLengthMeters, 32, `nativeRoad.junctionTemplates.clusters[${index}].approachLengthMeters`, 10, 50); const coreRadiusMeters = numberOption(item.coreRadiusMeters, 28, `nativeRoad.junctionTemplates.clusters[${index}].coreRadiusMeters`, 12, 80); @@ -272,7 +272,24 @@ function booleanOption(value, fallback, label) { throw new Error(`${label} must be boolean`); } +function toRoadCompilerInput(area) { + return { + areaId: area.id, + osmFile: area.input, + outDir: area.outputs.nativeRoadDir, + stagingDir: area.outputs.pipelineDir, + overridesFile: area.outputs.nativeRoadOverrides, + trafficSignalsFile: area.outputs.nativeTrafficSignals, + comparisonDir: area.outputs.geojsonDir, + options: { + edgeLines: area.nativeRoad.edgeLines, + junctionTemplates: area.nativeRoad.junctionTemplates, + }, + }; +} + module.exports = { normalizeAreaConfig, readAreaConfig, + toRoadCompilerInput, }; diff --git a/scripts/lib/traffic-signals.js b/scripts/lib/traffic-signals.js index e3f8d11..a203351 100644 --- a/scripts/lib/traffic-signals.js +++ b/scripts/lib/traffic-signals.js @@ -1,240 +1,12 @@ "use strict"; const fs = require("fs"); -const crypto = require("crypto"); -const { parseOsm } = require("./osm"); - -const EARTH_RADIUS = 6371008.8; -const CURB_OFFSET_METERS = 5.2; -const MAST_REACH_METERS = 4.5; -const SIGNAL_LAYOUT = Object.freeze({ - poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21, - mastHeightMeters: 6.25, headCenterHeightMeters: 6.25, - headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62, - lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18, - lensVerticalOffsetsMeters: [0.49, -0.01, -0.51], - countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05, - countdownWidthMeters: 0.82, countdownDepthMeters: 0.14, - countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0, -}); - -function buildTrafficSignalFeatures(stopLines, intersections, controls = []) { - const centers = (intersections.features || []).map((feature, index) => { - const point = polygonCenter(feature.geometry); - return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) }; - }).filter((entry) => entry.point); - const clusteredStops = new Map(); - for (const feature of stopLines.features || []) { - const clusterId = feature.properties?.cluster_id; - const point = polygonCenter(feature.geometry); - if (!clusterId || !point) continue; - if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []); - clusteredStops.get(clusterId).push(point); - } - for (const [clusterId, points] of clusteredStops) { - if (points.length < 3) continue; - const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]); - centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) }); - } - const candidates = []; - for (const feature of stopLines.features || []) { - const center = polygonCenter(feature.geometry); - if (!center) continue; - const clusterId = feature.properties?.cluster_id; - const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers); - if (!intersection || metersBetween(center, intersection.point) > 32) continue; - const axis = roadAxis(feature.geometry, center, intersection.point); - if (!axis) continue; - const right = [axis[1], -axis[0]]; - candidates.push({ - intersectionId: intersection.id, center, axis, - point: intersection.clusterId - ? moveMeters(center, right, CURB_OFFSET_METERS) - : moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS), - headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), - matchHeadingDegrees: intersection.clusterId - ? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI) - : null, - }); - } - const features = []; - for (const control of controls) { - const controlPoint = [Number(control.longitude), Number(control.latitude)]; - if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue; - const intersection = nearestCenter(controlPoint, centers); - if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue; - const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms); - const groups = phaseGroups(arms); - arms.forEach((candidate, index) => { - const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`; - const sourceWayId = String(candidate.osmArm?.wayId || "legacy"); - const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId); - const approachId = `${sourceWayId}:${neighborNodeId}`; - const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`; - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: candidate.point.slice() }, - properties: { - signal_uid: signalUid, display_id: signalUid, control_id: String(control.id), - approach_id: approachId, source_way_id: sourceWayId, - // These are independent assembly controls. heading_deg remains a - // migration hint for older native documents only. - mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90), - face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180), - phase_group: groups[index], - mast_reach_m: MAST_REACH_METERS, - stop_lon: candidate.center[0], stop_lat: candidate.center[1], - enabled: true, z_offset_m: 0, - }, - }); - }); - } - return validateTrafficSignalFeatures({ type: "FeatureCollection", features }); -} - -function validateTrafficSignalFeatures(collection) { - if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) { - throw new Error("Traffic signal assemblies must be a FeatureCollection"); - } - const uids = new Set(); - const displayIds = new Set(); - const features = collection.features.map((feature, index) => { - const label = `traffic signal feature ${index + 1}`; - if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) || - feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) { - throw new Error(`${label}: geometry must be a finite Point`); - } - const input = feature.properties || {}; - const text = (key, required = true) => { - const value = input[key] == null ? "" : String(input[key]).trim(); - if (required && !value) throw new Error(`${label}: missing ${key}`); - return value; - }; - const number = (key, options = {}) => { - if (input[key] === null || input[key] === undefined || input[key] === "") { - throw new Error(`${label}: missing ${key}`); - } - const value = Number(input[key]); - if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) { - throw new Error(`${label}: invalid ${key} '${input[key]}'`); - } - return value; - }; - const signalUid = text("signal_uid"); - if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`); - if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`); - uids.add(signalUid); - const displayId = text("display_id", false); - if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`); - if (displayId) displayIds.add(displayId); - const phaseGroup = number("phase_group", { min: 0, max: 1 }); - if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`); - const enabled = normalizeBoolean(input.enabled, label); - const controlId = text("control_id"); - const approachId = text("approach_id"); - const sourceWayId = text("source_way_id"); - if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`); - const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`; - if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`); - const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg")); - if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) { - throw new Error(`${label}: missing mast_heading_deg`); - } - if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) { - throw new Error(`${label}: missing face_heading_deg`); - } - const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === "" - ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90) - : normalizeDegrees(number("mast_heading_deg")); - const faceHeading = input.face_heading_deg == null || input.face_heading_deg === "" - ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180) - : normalizeDegrees(number("face_heading_deg")); - return { - type: "Feature", - geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) }, - properties: { - ...input, signal_uid: signalUid, display_id: displayId, - control_id: controlId, approach_id: approachId, - source_way_id: sourceWayId, - // Retain the legacy value only for migration compatibility. Runtime - // geometry is entirely defined by mast_heading_deg and face_heading_deg. - heading_deg: legacyHeading, - mast_heading_deg: mastHeading, face_heading_deg: faceHeading, - phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }), - stop_lon: number("stop_lon", { min: -180, max: 180 }), - stop_lat: number("stop_lat", { min: -90, max: 90 }), - enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }), - }, - }; - }); - return { type: "FeatureCollection", features }; -} - -function buildTrafficSignalsFromFeatures(collection) { - const normalized = validateTrafficSignalFeatures(collection); - const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => { - const p = feature.properties; - const point = feature.geometry.coordinates; - const mastAxis = headingVector(p.mast_heading_deg); - return { - id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id, - nodeKey: signalNodeKey(p.signal_uid), - controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id, - phaseGroup: p.phase_group, longitude: point[0], latitude: point[1], - stopLongitude: p.stop_lon, stopLatitude: p.stop_lat, - // Existing Blender readers require headingDegrees. It is a compatibility - // alias only; the independent mast/face fields below define all geometry. - headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg, - mastHeadingDegrees: p.mast_heading_deg, - faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m, - zOffsetMeters: p.z_offset_m, - pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m), - }; - }); - return { version: 3, layout: SIGNAL_LAYOUT, signals }; -} - -function signalNodeKey(signalUid) { - return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`; -} - -function reconcileTrafficSignalSourceReferences(collection, controls) { - const normalized = validateTrafficSignalFeatures(collection); - const approachesByControl = new Map((controls || []).map((control) => [ - String(control.id), - new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)), - ])); - const kept = []; - const dropped = []; - for (const [index, feature] of normalized.features.entries()) { - const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties; - const approaches = approachesByControl.get(controlId); - if (!approaches) { - dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` }); - continue; - } - if (!approaches.has(approachId)) { - dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` }); - continue; - } - kept.push(feature); - } - return { collection: { ...normalized, features: kept }, dropped }; -} - -function validateTrafficSignalSourceReferences(collection, controls) { - const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls); - if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`); - return reconciled; -} - -function buildTrafficSignals(stopLines, intersections, controls = []) { - return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls)); -} +const { parseOsm } = require("../../packages/road-compiler/src/osm"); +const trafficSignals = require("../../packages/road-compiler/src/traffic-signals"); function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) { const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls; - return buildTrafficSignalFeatures( + return trafficSignals.buildTrafficSignalFeatures( JSON.parse(fs.readFileSync(stopLinePath, "utf8")), JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls, ); @@ -244,87 +16,9 @@ function readTrafficSignals(editablePath, osmPath = null) { const collection = JSON.parse(fs.readFileSync(editablePath, "utf8")); if (osmPath) { const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls; - validateTrafficSignalSourceReferences(collection, controls); + trafficSignals.validateTrafficSignalSourceReferences(collection, controls); } - return buildTrafficSignalsFromFeatures(collection); + return trafficSignals.buildTrafficSignalsFromFeatures(collection); } -function normalizeBoolean(value, label) { - if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true; - if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false; - throw new Error(`${label}: invalid enabled '${value}'`); -} - -function uniqueApproachArms(candidates, controlPoint) { - const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) })) - .sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance); - const arms = []; - for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate); - return arms; -} - -function matchOsmArms(candidates, controlPoint, osmArms) { - const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) })); - if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint); - return osmArms.map((osmArm) => { - let bestIndex = -1; let bestDistance = Infinity; - remaining.forEach((item, index) => { - const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees); - const distance = item.matchHeadingDegrees == null - ? directedDistance - : Math.min( - angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees), - angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees), - ); - if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } - }); - const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm); - return { ...candidate, osmArm }; - }); -} - -function fallbackCandidate(controlPoint, osmArm) { - const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]]; - const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2); - return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true }; -} - -function phaseGroups(arms) { - const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups; - let main = [0, 1]; let best = -1; - for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } } - groups[main[0]] = 0; groups[main[1]] = 0; return groups; -} - -function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) { - const face = headingVector(faceHeadingDegrees); - const head = moveMeters(pole, mastAxis, mastReach); - const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset }); - const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters); - const faceRight = [-face[1], face[0]]; - const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters); - return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } }; -} - -function polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; } -function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; } -function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; } -function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; } -function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; } -function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; } -function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; } -function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; } -function normalizeDegrees(value) { return ((value % 360) + 360) % 360; } -function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); } - -module.exports = { - SIGNAL_LAYOUT, - signalNodeKey, - buildTrafficSignalFeatures, - validateTrafficSignalFeatures, - validateTrafficSignalSourceReferences, - buildTrafficSignalsFromFeatures, - buildTrafficSignals, - readTrafficSignalFeatures, - readTrafficSignals, -}; +module.exports = { ...trafficSignals, readTrafficSignalFeatures, readTrafficSignals }; diff --git a/scripts/lib/vehicle-route.js b/scripts/lib/vehicle-route.js index a6f6dd3..b2270a8 100644 --- a/scripts/lib/vehicle-route.js +++ b/scripts/lib/vehicle-route.js @@ -1,7 +1,7 @@ "use strict"; const fs = require("fs"); -const { parseOsm } = require("./osm"); +const { parseOsm } = require("../../packages/road-compiler/src/osm"); const { appendCoordinates, haversineMeters, @@ -10,7 +10,7 @@ const { orientPolyline, polylineLength, polylineMidpoint, -} = require("./lane-geometry"); +} = require("../../packages/road-compiler/src/geometry/lane-geometry"); const MAX_ROUTES = 5; const MAX_PATH_EDGES = 7; diff --git a/scripts/reimport-gpkg.js b/scripts/reimport-gpkg.js index 3ac959c..b9adfa0 100755 --- a/scripts/reimport-gpkg.js +++ b/scripts/reimport-gpkg.js @@ -17,7 +17,7 @@ const path = require("path"); const os = require("os"); const { execFileSync } = require("child_process"); const { qgisPaths } = require("./lib/tool-paths"); -const { parseOsm } = require("./lib/osm"); +const { parseOsm } = require("../packages/road-compiler/src/osm"); const { validateTrafficSignalSourceReferences, } = require("./lib/traffic-signals"); diff --git a/scripts/render-turn-lane-arrow-samples.js b/scripts/render-turn-lane-arrow-samples.js index c5fdd24..8c72da5 100644 --- a/scripts/render-turn-lane-arrow-samples.js +++ b/scripts/render-turn-lane-arrow-samples.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); -const { templateFor } = require("./lib/turn-lane-arrows"); +const { templateFor } = require("../packages/road-compiler/src/compile/turn-lane-arrows"); const output = path.resolve(process.argv[2] || path.join("outputs", "turn-lane-arrow-samples.svg")); const DISPLAY_SCALE = 60; diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js index f78f706..ca0af6d 100644 --- a/scripts/road-workbench.js +++ b/scripts/road-workbench.js @@ -6,10 +6,10 @@ const http = require("http"); const path = require("path"); const { execFileSync } = require("child_process"); const { readAreaConfig } = require("./lib/area-config"); -const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road"); -const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals"); +const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../packages/road-compiler/src/compile/native-road"); +const { generate, validateDocument, runtime } = require("../packages/road-compiler/src/native-traffic-signals"); const { compileArea, parseArgs } = require("./compile-native-roads"); -const { convertGeoJson } = require("./lib/gaode-junction-reference"); +const { convertGeoJson } = require("../packages/road-compiler/src/reference/gaode"); const repoRoot = path.resolve(__dirname, ".."); diff --git a/scripts/test-gaode-junction-reference.js b/scripts/test-gaode-junction-reference.js index a601884..f33f9b0 100644 --- a/scripts/test-gaode-junction-reference.js +++ b/scripts/test-gaode-junction-reference.js @@ -5,7 +5,7 @@ const assert = require("assert"); const fs = require("fs"); const os = require("os"); const path = require("path"); -const { gcj02ToWgs84, convertGeoJson, inspectReference } = require("./lib/gaode-junction-reference"); +const { gcj02ToWgs84, convertGeoJson, inspectReference } = require("../packages/road-compiler/src/reference/gaode"); const converted = gcj02ToWgs84([114.12864875054062, 30.460485279762146]); assert.ok(Math.abs(converted[0] - 114.1229659) < 0.00001); diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js index 9b8f54a..b7b03f3 100644 --- a/scripts/test-native-road.js +++ b/scripts/test-native-road.js @@ -5,7 +5,7 @@ const assert = require("assert"); const fs = require("fs"); const os = require("os"); const path = require("path"); -const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road"); +const { compileRoadModel, compileGeometry, validateOverrides } = require("../packages/road-compiler/src/compile/native-road"); const { compileArea } = require("./compile-native-roads"); const { checkArea } = require("./check-native-roads"); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 4ef8110..37b6fda 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -22,8 +22,8 @@ const { uTurnConnector, } = require("./lib/vehicle-route"); const { buildTrafficSignals } = require("./lib/traffic-signals"); -const { haversineMeters, laneCenterline } = require("./lib/lane-geometry"); -const { parseOsm } = require("./lib/osm"); +const { haversineMeters, laneCenterline } = require("../packages/road-compiler/src/geometry/lane-geometry"); +const { parseOsm } = require("../packages/road-compiler/src/osm"); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-")); const osmPath = path.join(tempDir, "fixture.osm"); diff --git a/scripts/test-traffic-signals.js b/scripts/test-traffic-signals.js index 16c8c0a..d579b66 100644 --- a/scripts/test-traffic-signals.js +++ b/scripts/test-traffic-signals.js @@ -11,8 +11,8 @@ const { buildTrafficSignalsFromFeatures, validateTrafficSignalFeatures, validateTrafficSignalSourceReferences, -} = require("./lib/traffic-signals"); -const { SCHEMA, loadOrGenerate } = require("./lib/native-traffic-signals"); +} = require("../packages/road-compiler/src/traffic-signals"); +const { SCHEMA, loadOrGenerate } = require("../packages/road-compiler/src/native-traffic-signals"); function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) { return { type: "Feature", geometry: { type: "Polygon", coordinates: [[ diff --git a/scripts/test-turn-lane-arrows.js b/scripts/test-turn-lane-arrows.js index 6ae88ca..b281a76 100644 --- a/scripts/test-turn-lane-arrows.js +++ b/scripts/test-turn-lane-arrows.js @@ -2,7 +2,7 @@ "use strict"; const assert = require("assert"); -const { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows"); +const { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("../packages/road-compiler/src/compile/turn-lane-arrows"); function node(id, lon, lat) { return { id, lon, lat, tags: {} }; } function way(id, refs, tags) { return { id, refs, tags }; } diff --git a/scripts/test-v2x-cesium-overlay.js b/scripts/test-v2x-cesium-overlay.js index d8248e6..e4db437 100644 --- a/scripts/test-v2x-cesium-overlay.js +++ b/scripts/test-v2x-cesium-overlay.js @@ -5,7 +5,7 @@ const assert = require("assert"); const fs = require("fs"); const path = require("path"); const vm = require("vm"); -const { gcj02ToWgs84: referenceGcj02ToWgs84 } = require("./lib/gaode-junction-reference"); +const { gcj02ToWgs84: referenceGcj02ToWgs84 } = require("../packages/road-compiler/src/reference/gaode"); const source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8"); const context = {