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:
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user