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