feat: parameterize complex junction geometry with Gaode reference

- 高德 GeoJSON 参考流程: `scripts/lib/gaode-junction-reference.js`
  与 `scripts/inspect-junction-reference.js` 将 GCJ-02 参考转换为 WGS84,
  按 node id/最近距离关联 OSM, 支持普通路口面和 `complex-cluster` 两种匹配。
- 复合路口模板 `complex-junction-v1`: `scripts/lib/complex-junction.js` 用参考
  几何校准 core 半径, 生成路口面、进口路面、斑马线、停止线、角部圆角与安全岛;
  拓扑/信号/连接全部沿用 OSM/native。
- 车道中心线控制要素避让: `compileLaneCenterlines` 现接收模板已产出的斑马线/停止线,
  新增 `trimLaneOutsideControls` 按到路口中心的半径定向裁剪; 标线源几何同步裁剪, 不再
  越过斑马线继续画到核心区。拓扑几何不变, connector 集合前后一致。
- 复合路口人行道转角: `buildComplexJunctionGeometry` 沿已定义的路缘生成 2m 宽转角带,
  复用圆角曲线, 通过 `islands` 通道并入 `sidewalk_surface`; 自交或坐标非有限时报
  `complex-junction-sidewalk-corner-fallback` 并跳过。
- 新增诊断: `complex-junction-configured-radius-ignored`、
  `lane-centerline-fully-inside-control`、`complex-junction-sidewalk-corner-fallback`。
- 死码清理: 移除未被调用的 `clusterApproachRing`。
- spec 更新: `.trellis/spec/pipeline/cli-and-stages.md` 复合路口小节补充控制要素
  避让顺序、人行道转角契约、Validation 矩阵三行; 索引新增导航。
- 任务产物 `08-19-gaode-junction-reference`: 8 条验收标准全部实测记录,
  Scope Drift / Verification Log / Known Gaps 三节沉淀本次工作。

Regression: test:native-road / test:road-workbench / test:preflight /
test:native-preview-traffic / test:package-contract / test:traffic-signals /
test:gaode-junction-reference 全绿; road:check ok=true, errors=[]。
This commit is contained in:
2026-08-21 11:59:03 +08:00
parent 9a8dbc1a74
commit 12aeda9a63
31 changed files with 2134 additions and 95 deletions

View File

@@ -3,12 +3,16 @@
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
} = require("./lib/traffic-signals");
const { SCHEMA, loadOrGenerate } = require("./lib/native-traffic-signals");
function rectangle(lon, lat, dx = 0.00003, dy = 0.000006) {
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
@@ -41,6 +45,28 @@ assert.deepEqual(
"technical ids are deterministic",
);
const clusteredStops = { type: "FeatureCollection", features: [
rectangle(119.99995, 30.00005, 0.000006, 0.00003), rectangle(120.00010, 30.00025),
rectangle(120.00035, 30.00005, 0.000006, 0.00003), rectangle(120.00010, 29.99985),
].map((feature) => ({ ...feature, properties: { cluster_id: "generic-complex" } })) };
const polarityAgnosticControl = { ...control, arms: [{ ...arms[0], headingDegrees: 90 }, ...arms.slice(1)] };
const ordinaryWithReversedArm = buildTrafficSignalFeatures(stops, intersections, [polarityAgnosticControl]);
assert.ok(
!new Set(stops.features.map(stopCenterKey)).has(signalStopKey(ordinaryWithReversedArm.features.find((feature) => feature.properties.source_way_id === "east"))),
"ordinary candidates retain directed matching",
);
const clustered = buildTrafficSignalFeatures(clusteredStops, { type: "FeatureCollection", features: [] }, [polarityAgnosticControl]);
assert.equal(clustered.features.length, 4, "complex stop lines form a signal group without an ordinary intersection surface");
assert.deepEqual(
new Set(clustered.features.map(signalStopKey)),
new Set(clusteredStops.features.map(stopCenterKey)),
"complex OSM arms consume each stop-line candidate exactly once",
);
assert.ok(clustered.features.every((feature) => {
const stop = [feature.properties.stop_lon, feature.properties.stop_lat];
return metersBetweenForTest(feature.geometry.coordinates, stop) > 4.8 && metersBetweenForTest(feature.geometry.coordinates, stop) < 5.6;
}), "complex signal poles are positioned from their matched stop lines");
const edited = structuredClone(cross);
const first = edited.features[0];
const originalStop = [first.properties.stop_lon, first.properties.stop_lat];
@@ -84,6 +110,19 @@ assert.equal(migrated.face_heading_deg, 222, "legacy heading preserves the histo
edited.features[1].properties.enabled = "0";
assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted");
function metersBetweenForTest(first, second) {
return Math.hypot((first[0] - second[0]) * 111320 * Math.cos(first[1] * Math.PI / 180), (first[1] - second[1]) * 111320);
}
function stopCenterKey(feature) {
const ring = feature.geometry.coordinates[0].slice(0, -1);
return `${ring.reduce((sum, point) => sum + point[0], 0) / ring.length},${ring.reduce((sum, point) => sum + point[1], 0) / ring.length}`;
}
function signalStopKey(feature) {
return `${feature.properties.stop_lon},${feature.properties.stop_lat}`;
}
const duplicateUid = structuredClone(cross);
duplicateUid.features[1].properties.signal_uid = duplicateUid.features[0].properties.signal_uid;
assert.throws(() => validateTrafficSignalFeatures(duplicateUid), /Duplicate signal_uid/);
@@ -129,4 +168,31 @@ const missingDirections = structuredClone(cross);
for (const key of ["heading_deg", "mast_heading_deg", "face_heading_deg"]) missingDirections.features[0].properties[key] = null;
assert.throws(() => validateTrafficSignalFeatures(missingDirections), /missing mast_heading_deg/);
const changedOsm = `
<osm>
<node id="control-1" lon="120.0001" lat="30.00005"><tag k="highway" v="traffic_signals" /></node>
<node id="w1" lon="119.9998" lat="30.00005" />
<node id="n1" lon="120.0001" lat="30.00035" />
<node id="e1" lon="120.0004" lat="30.00005" />
<way id="west"><nd ref="w1" /><nd ref="control-1" /><tag k="highway" v="primary" /></way>
<way id="north"><nd ref="control-1" /><nd ref="n1" /><tag k="highway" v="primary" /></way>
<way id="east"><nd ref="control-1" /><nd ref="e1" /><tag k="highway" v="primary" /></way>
</osm>`;
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "native-traffic-signals-"));
const generatedSignalPath = path.join(temporaryDirectory, "generated.json");
try {
fs.writeFileSync(generatedSignalPath, JSON.stringify({ schema: SCHEMA, provenance: "generated:osm-controls", assemblies: cross }));
const regenerated = loadOrGenerate(generatedSignalPath, changedOsm, stops, intersections);
assert.equal(regenerated.assemblies.features.length, 3, "stale OSM-generated signals must refresh after OSM approaches change");
fs.writeFileSync(generatedSignalPath, JSON.stringify({ schema: SCHEMA, provenance: "edited:workbench", assemblies: cross }));
assert.throws(
() => loadOrGenerate(generatedSignalPath, changedOsm, stops, intersections),
/approach_id .* is not present on OSM control/,
"manually maintained signals must not be silently replaced",
);
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
console.log("Traffic signal tests passed.");