fix: correct native road edge and junction boundary geometry

修复四处几何缺陷,并新增复杂路口候选识别与工作台调试视图。

几何修复:
- junctionBoundary 的进口边改用 cutback 处朝向 (headingAtCutback)。原先取节点端
  首段朝向, 与 roadRing 的端边不平行, 15 个进口有偏差 (最大 152°), 路口面与道路面
  之间张开楔形。凸包兜底路口 11 -> 8。
- compileEdgeLines 偏移量改用 totalWidth/2。原用单方向 road.widthMeters/2,
  双向段的路缘线画在车行道正中间 (实测中位偏差 1.63m = w/2)。同时以实际发出的
  路面为准过滤无路面的路, 并统一复杂路口的裁剪口径。
  顶点偏离 >0.5m: 64% -> 0%, 最大偏离 4.53m -> 0.06m (= 半个带宽, 理论最优)。
- roadRing 新增 removeOffsetFolds。定宽法线偏移无斜接限制, 转弯处相邻段较短时
  偏移点前后颠倒, 多边形折回自身, 按 even-odd 渲染成叶片状空洞。
  道路面自交 1 -> 0, 最尖角 0.1° -> 18.8°。
- junctionBoundary 新增 mergeParallelApproachPoints。双幅路两幅同向汇入时,
  四个侧点按角度排序后交错, 在交错点之间插入的圆角曲线从外圈深挖回节点,
  渲染成拱形凹口。合并近平行 (<25°) 进口为单一面。
  路口面最尖角 6.9° -> 全部 >=82.6°, 自交 0。

新增:
- detectComplexJunctionCandidates: 按"短链接连接的路口节点"聚类并做紧凑度过滤
  (直径 <=45m), 输出 complex-junction-candidate 诊断与建议参数。纯诊断,
  不启用模板、不改几何; test:native-road 断言了这条契约。
- road:workbench --debug: 地图标注候选编号与包络, 点选给出可粘贴配置片段,
  并提供 POST /api/junction-clusters 一键写入区域配置。写入前用 readAreaConfig
  校验、检测节点冲突、要求候选存在于最新编译结果, 原子落盘。

Regression: test:native-road / test:road-workbench / test:gaode-junction-reference /
test:preflight / test:native-preview-traffic / test:package-contract /
test:traffic-signals 全绿; road:check ok=true, errors=[]。
源 OSM 与参考 GeoJSON 校验和未变。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:45:20 +08:00
parent 8ecd794633
commit a16400ff96
4 changed files with 395 additions and 18 deletions

View File

@@ -10,6 +10,8 @@ import Style from "/vendor/ol/style/Style.js";
import Fill from "/vendor/ol/style/Fill.js";
import Stroke from "/vendor/ol/style/Stroke.js";
import CircleStyle from "/vendor/ol/style/Circle.js";
import Text from "/vendor/ol/style/Text.js";
import Polygon from "/vendor/ol/geom/Polygon.js";
import RegularShape from "/vendor/ol/style/RegularShape.js";
import Select from "/vendor/ol/interaction/Select.js";
import { click } from "/vendor/ol/events/condition.js";
@@ -61,6 +63,14 @@ controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked
const signalsToggle = document.createElement("label");
signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle);
// Only offered when the server was started with --debug; without it the state
// carries no candidates and an empty toggle would just be confusing.
const candidateAction = document.createElement("section");
document.querySelector(".inspector").insertBefore(candidateAction, document.querySelector(".inspector details"));
const candidatesToggle = document.createElement("label");
candidatesToggle.hidden = true;
candidatesToggle.innerHTML = '<input data-layer="junctionCandidates" type="checkbox" checked> 复杂路口候选debug';
signalsToggle.after(candidatesToggle);
const gaodeReferenceColors = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
let state;
@@ -97,16 +107,21 @@ const layers = {
signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }),
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
junctionCandidates: new VectorLayer({ source: source(), visible: true, zIndex: 25, style: (feature) => [
new Style({ fill: new Fill({ color: "rgba(219, 39, 119, .12)" }), stroke: new Stroke({ color: "#db2777", width: 2, lineDash: [8, 5] }) }),
new Style({ text: new Text({ text: `#${feature.get("index")} ${feature.get("nodeCount")}节点`, font: "bold 13px system-ui, sans-serif", fill: new Fill({ color: "#831843" }), stroke: new Stroke({ color: "#fff", width: 3 }), offsetY: -14 }) }),
] }),
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
};
const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.junctionCandidates, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.junctionCandidates, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
map.addInteraction(select);
select.on("select", ({ selected }) => {
const feature = selected[0];
if (!feature) return;
if (feature.get("candidate_index")) return selectJunctionCandidate(feature);
if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature);
if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature));
const junction = junctionForFeature(feature);
@@ -204,15 +219,108 @@ function updateSources() {
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue;
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors, (feature) => !feature.properties?.cluster_internal));
candidatesToggle.hidden = !(state.debug?.junctionCandidates || []).length;
renderJunctionCandidates();
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
}
// Draw one convex-ish hull per candidate cluster so the whole intersection is
// outlined, not just its centre, and label it with the same index the inspector
// and the console listing use.
function renderJunctionCandidates() {
const source = layers.junctionCandidates.getSource();
source.clear();
const list = state.debug?.junctionCandidates || [];
if (!list.length) return;
for (const candidate of list) {
const nodes = candidate.nodeIds.map((nodeId) => nodeCoordinate(nodeId)).filter(Boolean);
if (!nodes.length) continue;
const ring = candidateRing(nodes, Math.max(12, candidate.coreRadiusMeters * .6));
source.addFeature(new Feature({ geometry: new Polygon([ring]), candidate_index: candidate.index, index: candidate.index, nodeCount: candidate.nodeCount, candidate_id: candidate.id }));
}
}
function nodeCoordinate(nodeId) {
for (const road of state.compiled.model.roads) {
const at = road.sourceNodeIds.findIndex((item) => String(item) === String(nodeId));
if (at === 0) return road.centerline[0];
if (at === road.sourceNodeIds.length - 1) return road.centerline.at(-1);
}
return null;
}
// A rounded envelope around the member nodes: sample a circle of `padMeters`
// around each node and take the outer boundary by angle from the centroid.
function candidateRing(nodes, padMeters) {
const centre = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
const metresPerLon = 111320 * Math.cos(centre[1] * Math.PI / 180);
const points = [];
for (let degrees = 0; degrees < 360; degrees += 12) {
const radians = degrees * Math.PI / 180;
let best = null;
for (const node of nodes) {
const point = [node[0] + Math.sin(radians) * padMeters / metresPerLon, node[1] + Math.cos(radians) * padMeters / 111320];
const reach = (point[0] - centre[0]) * metresPerLon * Math.sin(radians) + (point[1] - centre[1]) * 111320 * Math.cos(radians);
if (!best || reach > best.reach) best = { point, reach };
}
points.push(fromLonLat(best.point));
}
return [...points, points[0]];
}
function selectJunctionCandidate(feature) {
const candidate = (state.debug?.junctionCandidates || []).find((item) => item.index === feature.get("candidate_index"));
if (!candidate) return;
selectedRoad = null; selectedMovement = null; selectedJunction = null;
form.hidden = true; hint.hidden = false; selectedJunctionPanel.hidden = true;
hint.textContent = `复杂路口候选 #${candidate.index}${candidate.nodeCount} 个节点,直径 ${candidate.diameterMeters} 米。下方是可直接粘贴到 config 的片段。`;
evidence.textContent = JSON.stringify({
说明: "复制到 config/areas/<区域>.json 的 nativeRoad.junctionTemplates.clusters 数组,然后重新编译",
片段: {
id: `cluster-${candidate.nodeIds[0]}`,
template: candidate.template,
nodeIds: candidate.nodeIds,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
},
实测: { 节点数: candidate.nodeCount, 直径米: candidate.diameterMeters, 最长内部连接米: candidate.longestLinkMeters, 最宽进口米: candidate.widestApproachMeters },
提示: "coreRadiusMeters 是按节点跨度估的起点,配好后按实际效果调整;有高德参考几何时它会被校准值覆盖。",
}, null, 2);
renderCandidateAction(candidate);
message(`已选中复杂路口候选 #${candidate.index}`);
}
// The accept button lives beside the snippet so the manual path stays available
// if the write is refused; both describe the same cluster.
function renderCandidateAction(candidate) {
candidateAction.replaceChildren();
const button = document.createElement("button");
button.type = "button";
button.textContent = `把候选 #${candidate.index} 加入配置并重新编译`;
button.onclick = async () => {
button.disabled = true;
message(`正在把候选 #${candidate.index} 写入区域配置...`);
try {
const response = await fetch("/api/junction-clusters", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: candidate.index }), cache: "no-store" });
const result = await response.json();
if (!response.ok || result.ok === false) throw new Error(result.error || `HTTP ${response.status}`);
state = result;
staged = [];
updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary();
candidateAction.replaceChildren();
hint.textContent = `已加入复杂路口 ${result.added.id}${result.added.nodeIds.length} 个节点),配置已更新并重新编译。`;
message(`已加入 ${result.added.id};如需撤销可 git checkout 区域配置`);
} catch (error) {
button.disabled = false;
message(`加入失败:${error.message}`);
}
};
candidateAction.append(button);
}
function laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; }
function effectiveLaneEnabled(connector) { const id = `车道连接:${laneId(connector, "from")}->${laneId(connector, "to")}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; }
function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; }
function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
function selectRoad(road, note, movement = null) {
candidateAction.replaceChildren();
selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement();
layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") }));
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
@@ -223,6 +331,7 @@ function selectRoad(road, note, movement = null) {
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`));
}
function selectJunction(feature) {
candidateAction.replaceChildren();
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);