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

@@ -246,9 +246,97 @@ function nearbyManualCandidates(endpoints, from) {
return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 }));
}
// A single physical intersection is often mapped as several nodes joined by
// short links: a dual carriageway crossing, a slip lane, a staggered junction.
// Each node then compiles its own surface and the shared area between them is
// left as ordinary road, which is what produces the width jumps and stray
// medians around those nodes. Report the clusters so they can be configured as
// `complex-junction-v1`. Detection is advisory only — it never enables a
// template or changes geometry, because flipping a junction between the
// ordinary and complex paths silently on an OSM edit would be unpredictable.
const COMPLEX_CANDIDATE_MAX_LINK_METERS = 30;
const COMPLEX_CANDIDATE_MIN_NODES = 2;
// Short links chain transitively, so a run of closely spaced junctions along
// one street unions into a single 'cluster' that is really a corridor. A real
// intersection stays compact, so bound the cluster by its own diameter: the
// surveyed 珠山湖大道 cluster spans 24.6 m on its four mapped nodes and 40.7 m
// once the neighbouring service-road junction is folded in.
const COMPLEX_CANDIDATE_MAX_DIAMETER_METERS = 45;
function detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics) {
const configured = new Set((options.junctionTemplates?.clusters || []).flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const segmentsByNode = new Map();
for (const endpoint of model.endpoints) {
const key = String(endpoint.nodeId);
if (!segmentsByNode.has(key)) segmentsByNode.set(key, new Set());
segmentsByNode.get(key).add(endpoint.roadId.replace(/:(forward|backward)$/, ""));
}
const junctionNodes = new Set([...segmentsByNode].filter(([, segments]) => segments.size >= 3).map(([nodeId]) => nodeId));
const parent = new Map();
const find = (id) => {
if (!parent.has(id)) parent.set(id, id);
while (parent.get(id) !== id) { parent.set(id, parent.get(parent.get(id))); id = parent.get(id); }
return id;
};
const union = (first, second) => { const a = find(first); const b = find(second); if (a !== b) parent.set(a, b); };
const links = new Map();
const seenSegments = new Set();
for (const road of model.roads) {
if (seenSegments.has(road.segmentId)) continue;
seenSegments.add(road.segmentId);
const start = String(road.sourceNodeIds[0]); const end = String(road.sourceNodeIds.at(-1));
if (start === end || !junctionNodes.has(start) || !junctionNodes.has(end)) continue;
const length = lineLengthMeters(road.centerline);
if (length > COMPLEX_CANDIDATE_MAX_LINK_METERS) continue;
union(start, end);
links.set(road.segmentId, { start, end, length });
}
const clusters = new Map();
for (const nodeId of parent.keys()) {
const root = find(nodeId);
if (!clusters.has(root)) clusters.set(root, []);
clusters.get(root).push(nodeId);
}
for (const nodeIds of clusters.values()) {
if (nodeIds.length < COMPLEX_CANDIDATE_MIN_NODES) continue;
if (nodeIds.some((nodeId) => configured.has(nodeId))) continue;
const points = nodeIds.map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
if (points.length !== nodeIds.length) continue;
const center = points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]);
const spreadMeters = Math.max(...points.map((point) => distanceMeters(center, point)));
let diameterMeters = 0;
for (let first = 0; first < points.length; first += 1) {
for (let second = first + 1; second < points.length; second += 1) diameterMeters = Math.max(diameterMeters, distanceMeters(points[first], points[second]));
}
if (diameterMeters > COMPLEX_CANDIDATE_MAX_DIAMETER_METERS) continue;
const inner = [...links.values()].filter((link) => nodeIds.includes(link.start) && nodeIds.includes(link.end));
const widths = nodeIds.flatMap((nodeId) => (junctionPlans.get(nodeId)?.approaches || []).map((approach) => approach.widthMeters));
const widestApproach = widths.length ? Math.max(...widths) : 0;
// Enough core to cover every member node plus the widest approach's half
// width, with a little slack. A starting point for tuning, not a result.
const suggestedCoreRadius = Math.min(80, Math.max(12, Math.round(spreadMeters + widestApproach / 2 + 4)));
diagnostics.push({
...diagnostic("info", `junction-cluster-candidate:${nodeIds.slice().sort().join("+")}`, nodeIds, "complex-junction-candidate",
`检测到 ${nodeIds.length} 个路口节点由 ${inner.length} 条短路段(最长 ${Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10} 米)相连,可能是同一个物理路口。当前按独立路口编译;如需合并请在 nativeRoad.junctionTemplates.clusters 中配置。`,
center),
suggestedCluster: {
template: "complex-junction-v1",
nodeIds: nodeIds.slice().sort(),
nodeCount: nodeIds.length,
spreadMeters: Math.round(spreadMeters * 10) / 10,
diameterMeters: Math.round(diameterMeters * 10) / 10,
longestLinkMeters: Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10,
widestApproachMeters: Math.round(widestApproach * 100) / 100,
coreRadiusMeters: suggestedCoreRadius,
},
});
}
}
function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model, options, diagnostics);
detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics);
const features = [];
const activeClusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
@@ -311,7 +399,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
diagnostics.push(...generated.diagnostics);
}
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, { crosswalks: generatedComplexCrosswalks, stopLines: generatedComplexStopLines });
const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans);
const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans, options, features);
const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines] };
const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options);
@@ -415,11 +503,30 @@ function laneWasClipped(original, visible) {
return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - .01;
}
function compileEdgeLines(model, overrides, junctionPlans) {
function compileEdgeLines(model, overrides, junctionPlans, options = {}, roadSurfaces = []) {
const features = [];
const clusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)]));
// Roads swallowed by a complex cluster never get a surface. Deriving their
// edge from the centerline anyway paints a curb across bare ground, so take
// the surfaces actually emitted as the authority on what can be outlined.
const surfaced = new Set(roadSurfaces.flatMap((feature) => String(feature.properties?.directional_road_ids || "").split(",")).filter(Boolean));
for (const road of model.roads) {
const line = trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
const bidirectional = model.roads.some((item) => item.segmentId === road.segmentId && item.id !== road.id);
if (surfaced.size && !surfaced.has(road.id)) continue;
// The road surface is one polygon per segment, centred on this centerline
// and spanning the sum of both directions. Deriving the edge from a single
// direction's width puts it half a carriageway inside the asphalt.
const directions = model.roads.filter((item) => item.segmentId === road.segmentId);
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
const bidirectional = directions.length > 1;
// Match the surface's trim exactly. A cluster road is cut at the cluster
// boundary, not at the ordinary junction cutback; using the cutback here
// runs the edge line out past the asphalt it is supposed to outline.
const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
const line = cluster?.template === "complex-junction-v1"
? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, cluster, clusterCenters.get(cluster.id))
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
// On a two-way segment, the inner edge is the road centre boundary and is
// owned by center_lines. Emit only each directional carriageway's outer
// edge; emitting both sides makes the layer look like a second centreline.
@@ -427,7 +534,8 @@ function compileEdgeLines(model, overrides, junctionPlans) {
for (const offset of offsets) {
const side = offset < 0 ? "right" : "left";
const style = edgeLineStyle(overrides, road.id, side);
const centerline = offsetLine(line, offset * road.widthMeters / 2);
const centerline = offsetLine(line, offset * totalWidth / 2);
if (!centerline) continue;
if (style.pattern === "solid") {
const ring = roadRing(centerline, .12);
if (ring) features.push(edgeLineFeature(road, side, style, ring));
@@ -1180,17 +1288,64 @@ function junctionApproaches(model, endpoints) {
});
}
// `roadRing` builds the trimmed road's end edge from the direction at the
// cutback point, not at the node. Taking the node-side heading here instead
// leaves the two edges non-parallel whenever the way bends inside the cutback,
// and the junction surface then opens a wedge against the road it should meet.
function headingAtCutback(line, cutbackMeters) {
const point = pointAlongLine(line, cutbackMeters);
if (!point) return null;
let traversed = 0;
for (let index = 1; index < line.length; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
// The first original vertex past the cutback is what the trimmed line
// carries as its second point, so match that pair exactly.
if (traversed > cutbackMeters + 1e-9) return headingDegrees(point, line[index]);
}
return headingAtEndpoint(line);
}
// A dual carriageway reaches a node as two approaches on almost the same
// bearing. Their four side points interleave once sorted by angle, and because
// consecutive points then belong to different segments a rounded corner gets
// inserted between each interleaved pair. Those curves dive back toward the
// node and render as arch-shaped holes in the junction. Merge such approaches
// into one face and keep only its outermost edges.
const PARALLEL_APPROACH_DEGREES = 25;
function signedHeadingDelta(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
function mergeParallelApproachPoints(points, node) {
const groups = [];
for (const item of points) {
const group = groups.find((candidate) => Math.abs(signedHeadingDelta(candidate.heading - item.outwardHeading)) < PARALLEL_APPROACH_DEGREES);
if (group) { group.points.push(item); continue; }
groups.push({ heading: item.outwardHeading, points: [item] });
}
return groups.flatMap((group) => {
const segments = [...new Set(group.points.map((item) => item.segmentId))];
if (segments.length < 2) return group.points;
// Order across the face by bearing measured from the group's own heading,
// so the comparison never straddles the +/-180 discontinuity.
const sorted = [...group.points].sort((first, second) =>
signedHeadingDelta(headingDegrees(node, first.point) - group.heading) - signedHeadingDelta(headingDegrees(node, second.point) - group.heading));
const merged = segments.sort().join("+");
return [sorted[0], sorted.at(-1)].map((item) => ({ ...item, segmentId: merged, sourceWayKey: merged }));
});
}
function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) {
const points = [];
for (const approach of approaches) {
const cutback = pointAlongLine(approach.line, cutbackMeters);
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const heading = headingAtCutback(approach.line, cutbackMeters) ?? headingAtEndpoint(approach.line);
const half = approach.widthMeters * approachWidthMultiplier / 2;
points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
}
const ordered = points.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point));
const faces = mergeParallelApproachPoints(points, node);
const ordered = faces.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point));
if (ordered.length < 3) return { points: [], mode: "approach-envelope" };
const boundary = [];
let rounded = 0;
@@ -1447,6 +1602,31 @@ function circleRing(center, radius, segments) {
return ring;
}
// Offsetting every vertex by a fixed distance along its averaged normal has no
// miter limit: where the centerline turns and the neighbouring segment is short
// — typically the stub left after junction trimming — consecutive offset points
// swap order and the edge doubles back. The ring then self-intersects and the
// folded lobe renders as a hole. Drop the reversed vertices so each offset
// edge keeps travelling the same way as the centerline segment it follows.
function removeOffsetFolds(offset, centerline) {
let kept = offset.map((point, index) => ({ point, index }));
for (let guard = 0; guard < offset.length && kept.length > 2; guard += 1) {
let removed = false;
for (let position = 0; position < kept.length - 1; position += 1) {
const from = kept[position]; const to = kept[position + 1];
const alongCenter = [centerline[to.index][0] - centerline[from.index][0], centerline[to.index][1] - centerline[from.index][1]];
const alongOffset = [to.point[0] - from.point[0], to.point[1] - from.point[1]];
if (alongCenter[0] * alongOffset[0] + alongCenter[1] * alongOffset[1] >= 0) continue;
// Keep both termini: they are where the surface meets its junctions.
kept.splice(position + 1 === kept.length - 1 ? position : position + 1, 1);
removed = true;
break;
}
if (!removed) break;
}
return kept.map((item) => item.point);
}
function roadRing(line, width) {
if (line.length < 2 || !Number.isFinite(width)) return null;
const origin = line[0];
@@ -1458,10 +1638,13 @@ function roadRing(line, width) {
const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy);
if (length < 0.01) return null;
const nx = -dy / length * half; const ny = dx / length * half;
left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin));
right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin));
left.push([points[i][0] + nx, points[i][1] + ny]);
right.push([points[i][0] - nx, points[i][1] - ny]);
}
const ring = [...left, ...right.reverse(), left[0]];
const leftEdge = removeOffsetFolds(left, points).map((point) => unproject(point, origin));
const rightEdge = removeOffsetFolds(right, points).map((point) => unproject(point, origin));
if (leftEdge.length < 2 || rightEdge.length < 2) return null;
const ring = [...leftEdge, ...rightEdge.reverse(), leftEdge[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}