refactor: move road compiler core into package

This commit is contained in:
2026-08-25 17:12:58 +08:00
parent 5220c6b2a2
commit 73707dabbe
35 changed files with 11739 additions and 454 deletions

View 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.

View File

@@ -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

View File

@@ -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/` 下无搬迁文件的残留副本

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View 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"
}
}

View 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 };

View File

@@ -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

View File

@@ -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;

View 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"),
};

View 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,
};

File diff suppressed because it is too large Load Diff

View 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");

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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 = {};

View File

@@ -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,
}; };

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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");

View File

@@ -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;

View File

@@ -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, "..");

View File

@@ -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);

View File

@@ -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");

View File

@@ -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");

View File

@@ -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: [[

View File

@@ -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 }; }

View File

@@ -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 = {