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:
@@ -23,20 +23,38 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
|
||||
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 intersection = nearestCenter(center, centers);
|
||||
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]];
|
||||
const farSide = moveMeters(intersection.point, axis, intersection.radius + 3.2);
|
||||
candidates.push({
|
||||
intersectionId: intersection.id, center, axis,
|
||||
point: moveMeters(farSide, right, CURB_OFFSET_METERS),
|
||||
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 = [];
|
||||
@@ -180,25 +198,34 @@ function signalNodeKey(signalUid) {
|
||||
return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function validateTrafficSignalSourceReferences(collection, controls) {
|
||||
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 } = feature.properties;
|
||||
const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties;
|
||||
const approaches = approachesByControl.get(controlId);
|
||||
if (!approaches) {
|
||||
throw new Error(`traffic signal feature ${index + 1}: control_id '${controlId}' is not present in the current OSM`);
|
||||
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)) {
|
||||
throw new Error(
|
||||
`traffic signal feature ${index + 1}: approach_id '${approachId}' is not present on OSM control '${controlId}'`,
|
||||
);
|
||||
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 normalized;
|
||||
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 = []) {
|
||||
@@ -237,11 +264,20 @@ function uniqueApproachArms(candidates, controlPoint) {
|
||||
}
|
||||
|
||||
function matchOsmArms(candidates, controlPoint, osmArms) {
|
||||
const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)) }));
|
||||
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 distance = angularDistance(item.armHeading, osmArm.headingDegrees); if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } });
|
||||
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 };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user