refactor: move road compiler core into package
This commit is contained in:
53
.trellis/tasks/08-25-rc-p1-package-boundary/design.md
Normal file
53
.trellis/tasks/08-25-rc-p1-package-boundary/design.md
Normal file
@@ -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.
|
||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
```bash
|
```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 \
|
node scripts/road-parity.js --config config/areas/$a.json \
|
||||||
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a"
|
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] 三区域基线全绿(否则回 Phase 0)
|
- [ ] 两个有效区域基线全绿(否则回 Phase 0)
|
||||||
|
|
||||||
## Step 1 — K1 矩阵(动代码之前)
|
## Step 1 — K1 矩阵(动代码之前)
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ packages/road-compiler/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# parity
|
# 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 \
|
node scripts/road-parity.js --config config/areas/$a.json \
|
||||||
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a"
|
--compare .trellis/tasks/08-25-road-compiler-extraction/baseline/$a.json || echo "FAIL $a"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -66,9 +66,9 @@
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## 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.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.4 `grep -rn "area-config\|config/areas" packages/road-compiler/` 无命中
|
||||||
- [ ] AC1.5 `packages/road-compiler/` 内 `npm test` 不访问宿主 `inputs/` 或 `outputs/`
|
- [ ] AC1.5 `packages/road-compiler/` 内 `npm test` 不访问宿主 `inputs/` 或 `outputs/`
|
||||||
- [ ] AC1.6 `scripts/lib/` 下无搬迁文件的残留副本
|
- [ ] AC1.6 `scripts/lib/` 下无搬迁文件的残留副本
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "rc-p1-package-boundary",
|
"name": "rc-p1-package-boundary",
|
||||||
"title": "Phase 1:包边界(不换仓库)",
|
"title": "Phase 1:包边界(不换仓库)",
|
||||||
"description": "在本仓库内建 packages/road-compiler,移入编译器模块,用窄输入契约替换 readAreaConfig 耦合,产物对基线逐字节一致",
|
"description": "在本仓库内建 packages/road-compiler,移入编译器模块,用窄输入契约替换 readAreaConfig 耦合,产物对基线逐字节一致",
|
||||||
"status": "planning",
|
"status": "in_progress",
|
||||||
"dev_type": null,
|
"dev_type": null,
|
||||||
"scope": null,
|
"scope": null,
|
||||||
"package": null,
|
"package": null,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
{
|
{
|
||||||
"id": "zhushanhu-fengshu-complex",
|
"id": "zhushanhu-fengshu-complex",
|
||||||
"template": "complex-junction-v1",
|
"template": "complex-junction-v1",
|
||||||
"referenceFile": "/Users/que01/osm2streets-qgis-workflow/inputs/osm/珠山湖大道(枫树二路)口.geojson",
|
"referenceFile": "../../inputs/osm/珠山湖大道(枫树二路)口.geojson",
|
||||||
"approachWidthMultiplier": 1.45,
|
"approachWidthMultiplier": 1.45,
|
||||||
"approachLengthMeters": 32,
|
"approachLengthMeters": 32,
|
||||||
"coreRadiusMeters": 28,
|
"coreRadiusMeters": 28,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/road-compiler"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/interactive-build.js",
|
"build": "node scripts/interactive-build.js",
|
||||||
"build:area": "node scripts/build-area.js",
|
"build:area": "node scripts/build-area.js",
|
||||||
|
|||||||
10
packages/road-compiler/package.json
Normal file
10
packages/road-compiler/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
141
packages/road-compiler/src/compile/compiler.js
Normal file
141
packages/road-compiler/src/compile/compiler.js
Normal file
@@ -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 };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { convertGeoJson, boundsOf } = require("./gaode-junction-reference");
|
const { convertGeoJson, boundsOf } = require("../reference/gaode");
|
||||||
const metricsCache = new WeakMap();
|
const metricsCache = new WeakMap();
|
||||||
const CORNER_FILLET_SEGMENTS = 12;
|
const CORNER_FILLET_SEGMENTS = 12;
|
||||||
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
|
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
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 LANE_WIDTH_METERS = 3.2;
|
||||||
const PLACEMENT_DISTANCE_METERS = 9;
|
const PLACEMENT_DISTANCE_METERS = 9;
|
||||||
const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18;
|
const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18;
|
||||||
13
packages/road-compiler/src/index.js
Normal file
13
packages/road-compiler/src/index.js
Normal file
@@ -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"),
|
||||||
|
};
|
||||||
330
packages/road-compiler/src/traffic-signals.js
Normal file
330
packages/road-compiler/src/traffic-signals.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
11091
packages/road-compiler/test/fixtures/fengshu-er-road.osm
vendored
Normal file
11091
packages/road-compiler/test/fixtures/fengshu-er-road.osm
vendored
Normal file
File diff suppressed because it is too large
Load Diff
18
packages/road-compiler/test/index.js
Normal file
18
packages/road-compiler/test/index.js
Normal file
@@ -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");
|
||||||
@@ -6,7 +6,7 @@ const os = require("os");
|
|||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
const { JsStreetNetwork } = require("osm2streets-js-node");
|
const { JsStreetNetwork } = require("osm2streets-js-node");
|
||||||
const { qgisPaths } = require("./lib/tool-paths");
|
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 { readTrafficSignalFeatures } = require("./lib/traffic-signals");
|
||||||
const {
|
const {
|
||||||
SCENE_LAYERS,
|
SCENE_LAYERS,
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { readAreaConfig } = require("./lib/area-config");
|
const { readAreaConfig, toRoadCompilerInput } = require("./lib/area-config");
|
||||||
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
const { compileInput } = require("../packages/road-compiler/src/compile/compiler");
|
||||||
const { loadOrGenerate, runtime } = require("./lib/native-traffic-signals");
|
|
||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, "..");
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
@@ -21,112 +19,8 @@ function parseArgs(argv) {
|
|||||||
|
|
||||||
function compileArea(configPath) {
|
function compileArea(configPath) {
|
||||||
const area = readAreaConfig(configPath, { repoRoot });
|
const area = readAreaConfig(configPath, { repoRoot });
|
||||||
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
|
const compiled = compileInput(toRoadCompilerInput(area));
|
||||||
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
|
return { ...compiled, area };
|
||||||
// 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.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
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) {
|
function parseArgs(argv) {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function readAreaConfig(file, options = {}) {
|
|||||||
if (!fs.existsSync(file)) {
|
if (!fs.existsSync(file)) {
|
||||||
throw new Error(`Config file not found: ${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 = {}) {
|
function normalizeAreaConfig(raw, options = {}) {
|
||||||
@@ -113,7 +113,7 @@ function normalizeAreaConfig(raw, options = {}) {
|
|||||||
},
|
},
|
||||||
nativeRoad: {
|
nativeRoad: {
|
||||||
edgeLines: booleanOption(raw.nativeRoad?.edgeLines, false, "nativeRoad.edgeLines"),
|
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 || {
|
osm2streets: raw.osm2streets || {
|
||||||
debug_each_step: false,
|
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 (raw === undefined || raw === null) return { enabled: false, references: [] };
|
||||||
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("nativeRoad.junctionTemplates must be an object");
|
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");
|
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 nodeId = requireText(item.nodeId, `nativeRoad.junctionTemplates.references[${index}].nodeId`);
|
||||||
const template = item.template ?? "cross-v1";
|
const template = item.template ?? "cross-v1";
|
||||||
if (template !== "cross-v1") throw new Error(`nativeRoad.junctionTemplates.references[${index}].template must be \"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";
|
const coordinateSystem = item.coordinateSystem ?? "GCJ-02";
|
||||||
if (coordinateSystem !== "GCJ-02") throw new Error(`nativeRoad.junctionTemplates.references[${index}].coordinateSystem must be \"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);
|
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`);
|
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";
|
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\"`);
|
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 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 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);
|
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`);
|
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 = {
|
module.exports = {
|
||||||
normalizeAreaConfig,
|
normalizeAreaConfig,
|
||||||
readAreaConfig,
|
readAreaConfig,
|
||||||
|
toRoadCompilerInput,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,240 +1,12 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const crypto = require("crypto");
|
const { parseOsm } = require("../../packages/road-compiler/src/osm");
|
||||||
const { parseOsm } = require("./osm");
|
const trafficSignals = require("../../packages/road-compiler/src/traffic-signals");
|
||||||
|
|
||||||
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) {
|
function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) {
|
||||||
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
||||||
return buildTrafficSignalFeatures(
|
return trafficSignals.buildTrafficSignalFeatures(
|
||||||
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
|
JSON.parse(fs.readFileSync(stopLinePath, "utf8")),
|
||||||
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
|
JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls,
|
||||||
);
|
);
|
||||||
@@ -244,87 +16,9 @@ function readTrafficSignals(editablePath, osmPath = null) {
|
|||||||
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
|
const collection = JSON.parse(fs.readFileSync(editablePath, "utf8"));
|
||||||
if (osmPath) {
|
if (osmPath) {
|
||||||
const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls;
|
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) {
|
module.exports = { ...trafficSignals, readTrafficSignalFeatures, readTrafficSignals };
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { parseOsm } = require("./osm");
|
const { parseOsm } = require("../../packages/road-compiler/src/osm");
|
||||||
const {
|
const {
|
||||||
appendCoordinates,
|
appendCoordinates,
|
||||||
haversineMeters,
|
haversineMeters,
|
||||||
@@ -10,7 +10,7 @@ const {
|
|||||||
orientPolyline,
|
orientPolyline,
|
||||||
polylineLength,
|
polylineLength,
|
||||||
polylineMidpoint,
|
polylineMidpoint,
|
||||||
} = require("./lane-geometry");
|
} = require("../../packages/road-compiler/src/geometry/lane-geometry");
|
||||||
|
|
||||||
const MAX_ROUTES = 5;
|
const MAX_ROUTES = 5;
|
||||||
const MAX_PATH_EDGES = 7;
|
const MAX_PATH_EDGES = 7;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const path = require("path");
|
|||||||
const os = require("os");
|
const os = require("os");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
const { qgisPaths } = require("./lib/tool-paths");
|
const { qgisPaths } = require("./lib/tool-paths");
|
||||||
const { parseOsm } = require("./lib/osm");
|
const { parseOsm } = require("../packages/road-compiler/src/osm");
|
||||||
const {
|
const {
|
||||||
validateTrafficSignalSourceReferences,
|
validateTrafficSignalSourceReferences,
|
||||||
} = require("./lib/traffic-signals");
|
} = require("./lib/traffic-signals");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
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 output = path.resolve(process.argv[2] || path.join("outputs", "turn-lane-arrow-samples.svg"));
|
||||||
const DISPLAY_SCALE = 60;
|
const DISPLAY_SCALE = 60;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ const http = require("http");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
const { readAreaConfig } = require("./lib/area-config");
|
const { readAreaConfig } = require("./lib/area-config");
|
||||||
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
|
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../packages/road-compiler/src/compile/native-road");
|
||||||
const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals");
|
const { generate, validateDocument, runtime } = require("../packages/road-compiler/src/native-traffic-signals");
|
||||||
const { compileArea, parseArgs } = require("./compile-native-roads");
|
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, "..");
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const assert = require("assert");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
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]);
|
const converted = gcj02ToWgs84([114.12864875054062, 30.460485279762146]);
|
||||||
assert.ok(Math.abs(converted[0] - 114.1229659) < 0.00001);
|
assert.ok(Math.abs(converted[0] - 114.1229659) < 0.00001);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const assert = require("assert");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
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 { compileArea } = require("./compile-native-roads");
|
||||||
const { checkArea } = require("./check-native-roads");
|
const { checkArea } = require("./check-native-roads");
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ const {
|
|||||||
uTurnConnector,
|
uTurnConnector,
|
||||||
} = require("./lib/vehicle-route");
|
} = require("./lib/vehicle-route");
|
||||||
const { buildTrafficSignals } = require("./lib/traffic-signals");
|
const { buildTrafficSignals } = require("./lib/traffic-signals");
|
||||||
const { haversineMeters, laneCenterline } = require("./lib/lane-geometry");
|
const { haversineMeters, laneCenterline } = require("../packages/road-compiler/src/geometry/lane-geometry");
|
||||||
const { parseOsm } = require("./lib/osm");
|
const { parseOsm } = require("../packages/road-compiler/src/osm");
|
||||||
|
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
||||||
const osmPath = path.join(tempDir, "fixture.osm");
|
const osmPath = path.join(tempDir, "fixture.osm");
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const {
|
|||||||
buildTrafficSignalsFromFeatures,
|
buildTrafficSignalsFromFeatures,
|
||||||
validateTrafficSignalFeatures,
|
validateTrafficSignalFeatures,
|
||||||
validateTrafficSignalSourceReferences,
|
validateTrafficSignalSourceReferences,
|
||||||
} = require("./lib/traffic-signals");
|
} = require("../packages/road-compiler/src/traffic-signals");
|
||||||
const { SCHEMA, loadOrGenerate } = require("./lib/native-traffic-signals");
|
const { SCHEMA, loadOrGenerate } = require("../packages/road-compiler/src/native-traffic-signals");
|
||||||
|
|
||||||
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
|
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
|
||||||
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const assert = require("assert");
|
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 node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
|
||||||
function way(id, refs, tags) { return { id, refs, tags }; }
|
function way(id, refs, tags) { return { id, refs, tags }; }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const assert = require("assert");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const vm = require("vm");
|
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 source = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8");
|
||||||
const context = {
|
const context = {
|
||||||
|
|||||||
Reference in New Issue
Block a user