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