- 高德 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=[]。
469 lines
32 KiB
JavaScript
469 lines
32 KiB
JavaScript
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const { convertGeoJson, boundsOf } = require("./gaode-junction-reference");
|
|
const metricsCache = new WeakMap();
|
|
const CORNER_FILLET_SEGMENTS = 12;
|
|
// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band
|
|
// lines up with the straight strips it joins.
|
|
const SIDEWALK_WIDTH_METERS = 2;
|
|
// The straight strips are trimmed against the cluster boundary using the road
|
|
// centerline, so they stop a little beyond the carriageway end. Run the corner
|
|
// past that end and let the two overlap rather than chase an exact seam.
|
|
const SIDEWALK_CORNER_OVERRUN_METERS = 6;
|
|
|
|
function buildComplexJunctionGeometry(model, cluster, helpers) {
|
|
const nodeIds = new Set(cluster.nodeIds.map(String));
|
|
const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean);
|
|
if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] };
|
|
const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
|
|
const approaches = [];
|
|
const carriageways = [];
|
|
for (const [nodeId, plan] of helpers.junctionPlans) {
|
|
if (!nodeIds.has(String(nodeId))) continue;
|
|
for (const approach of plan.approaches) {
|
|
const endpoint = approach.line.at(-1);
|
|
if (nodes.some((node) => node !== plan.node && helpers.distanceMeters(endpoint, node) < 4)) continue;
|
|
const heading = helpers.headingAtEndpoint(approach.line);
|
|
const length = helpers.lineLengthMeters(approach.line);
|
|
carriageways.push({ nodeId, approach, plan, heading, length });
|
|
if (approaches.some((item) => Math.abs(normalizeHeading(item.heading - heading)) < 20)) continue;
|
|
approaches.push({ nodeId, approach, plan, heading, length });
|
|
}
|
|
}
|
|
if (approaches.length < 3) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-approaches", "复合路口无法识别足够的外部进口。", center)] };
|
|
const { calibration, coreRadius } = complexJunctionMetrics(cluster);
|
|
const sorted = [...approaches].sort((a, b) => a.heading - b.heading);
|
|
const arms = sorted.map((representative) => ({
|
|
representative,
|
|
heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)),
|
|
members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20),
|
|
}));
|
|
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
|
|
const boundaryParts = [];
|
|
const crosswalks = [];
|
|
const stopLines = [];
|
|
const islands = [];
|
|
const armCrosswalkRadius = coreRadius * .68;
|
|
for (const item of carriageways) {
|
|
const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers);
|
|
const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers);
|
|
const outerHalf = item.approach.widthMeters / 2;
|
|
const innerHalf = outerHalf;
|
|
boundaryParts.push({ item, outer, inner, outerHalf, innerHalf });
|
|
const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId));
|
|
if (incomingRoad) {
|
|
// Keep the stop bar just outside the road crosswalk. The previous fixed
|
|
// core-radius offset placed it nearly ten metres beyond the crossing.
|
|
const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers);
|
|
const ring = [
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24),
|
|
];
|
|
stopLines.push({ type: "Feature", properties: { native_id: `complex-stop-line:${cluster.id}:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-stop-line", road_id: incomingRoad.id, node_id: item.nodeId, direction: item.heading, provenance: "native-road-complex-junction-stop-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
|
}
|
|
}
|
|
// The four support lines provide a common corner frame, but each long
|
|
// crossing remains clipped to its OSM-derived road envelope. Corner islands
|
|
// fill the remaining frame gaps; crossings must never do that job.
|
|
for (const arm of arms) {
|
|
const envelope = armEnvelopeAtRadius(arm, armCrosswalkRadius, center, helpers);
|
|
if (!envelope) continue;
|
|
arm.crosswalkFrame = {
|
|
center: envelope.center,
|
|
groupDepth: 3.4,
|
|
supportHeading: normalizeHeading(arm.heading + 90),
|
|
envelopeWidthMeters: envelope.widthMeters,
|
|
};
|
|
}
|
|
const frameCorners = arms.map((arm, index) => {
|
|
const next = arms[(index + 1) % arms.length];
|
|
const delta = positiveHeadingDelta(arm.heading, next.heading);
|
|
if (delta < 45 || delta > 135 || !arm.crosswalkFrame || !next.crosswalkFrame) return null;
|
|
return supportLineIntersection(arm.crosswalkFrame, next.crosswalkFrame, center);
|
|
});
|
|
for (let index = 0; index < arms.length; index += 1) {
|
|
const arm = arms[index];
|
|
if (!arm.crosswalkFrame) continue;
|
|
const item = arm.representative;
|
|
const roadEdgeInset = .35;
|
|
const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2);
|
|
const endpoints = [
|
|
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2),
|
|
helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2),
|
|
];
|
|
const groupDepth = arm.crosswalkFrame.groupDepth;
|
|
const stripeWidth = .42;
|
|
const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1);
|
|
const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0;
|
|
arm.crosswalkFrame.center = midpoint(...endpoints);
|
|
arm.crosswalkFrame.endpoints = endpoints;
|
|
arm.crosswalkFrame.spanMeters = usableSpan;
|
|
arm.crosswalkFrame.roadEdgeInsetMeters = roadEdgeInset;
|
|
for (let stripe = 0; stripe < stripeCount; stripe += 1) {
|
|
const along = stripeWidth / 2 + stripe * stripeSpacing;
|
|
const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along);
|
|
const ring = [
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2),
|
|
helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2),
|
|
];
|
|
crosswalks.push({ type: "Feature", properties: { native_id: `complex-crosswalk:${cluster.id}:${item.nodeId}:${item.approach.segmentId}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-crosswalk", crossing_node_id: item.nodeId, road_id: item.approach.roadIds[0], direction: arm.heading, radial_distance_m: armCrosswalkRadius, span_m: usableSpan, road_envelope_span_m: arm.crosswalkFrame.envelopeWidthMeters, road_edge_inset_m: roadEdgeInset, group_depth_m: groupDepth, stripe_width_m: stripeWidth, stripe_spacing_m: stripeSpacing, frame_center: arm.crosswalkFrame.center, frame_support_heading: arm.crosswalkFrame.supportHeading, provenance: "native-road-complex-junction-crosswalk/v6-road-clipped" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
|
}
|
|
}
|
|
// The four arm groups are the sides of one pedestrian frame. Each diagonal
|
|
// group is anchored at the intersection of its adjacent side support lines,
|
|
// so all eight groups stay one composition when the OSM arms are skewed.
|
|
for (let index = 0; index < arms.length; index += 1) {
|
|
const first = arms[index];
|
|
const second = arms[(index + 1) % arms.length];
|
|
const delta = positiveHeadingDelta(first.heading, second.heading);
|
|
if (delta < 45 || delta > 135) continue;
|
|
if (!first.crosswalkFrame || !second.crosswalkFrame) continue;
|
|
const bisector = normalizeHeading(first.heading + delta / 2);
|
|
const frameCorner = frameCorners[index];
|
|
if (!frameCorner) continue;
|
|
const cornerStripeSpacing = .62;
|
|
const cornerStripeWidth = .4;
|
|
const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2;
|
|
const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0];
|
|
const outerEdgeAtCorner = (arm) => {
|
|
const endpoint = endpointForCorner(arm);
|
|
return [arm.heading, arm.heading + 180]
|
|
.map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2))
|
|
.sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0];
|
|
};
|
|
const islandBaseGap = .05;
|
|
const islandApexOffset = 1.5;
|
|
const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap));
|
|
const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset);
|
|
const islandCrossingClearance = .2;
|
|
const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth;
|
|
const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector)));
|
|
const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector);
|
|
const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset);
|
|
const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24);
|
|
islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } });
|
|
|
|
let cornerCrossingHalfSpan = .4;
|
|
for (let stripe = 0; stripe < 6; stripe += 1) {
|
|
const stripeOffset = (stripe - 2.5) * cornerStripeSpacing;
|
|
const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset);
|
|
const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector);
|
|
const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers);
|
|
if (!curbPair) continue;
|
|
const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2);
|
|
cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan);
|
|
const stripePair = [
|
|
helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan),
|
|
helpers.offsetCoordinate(stripeCenter, bisector + 90, halfSpan),
|
|
];
|
|
const ring = [
|
|
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
|
|
helpers.offsetCoordinate(stripePair[1], bisector, -cornerStripeWidth / 2),
|
|
helpers.offsetCoordinate(stripePair[1], bisector, cornerStripeWidth / 2),
|
|
helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2),
|
|
helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2),
|
|
];
|
|
crosswalks.push({ type: "Feature", properties: { native_id: `complex-corner-crosswalk:${cluster.id}:${index + 1}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-corner-crosswalk", corner_index: index + 1, direction: bisector, radial_distance_m: stripeRadius, frame_corner: frameCorner, from_heading: first.heading, to_heading: second.heading, provenance: "native-road-complex-junction-corner-crosswalk/v3" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
|
}
|
|
}
|
|
// Each carriageway ends in its own rectangle, so adjacent arms meet at a
|
|
// sharp notch instead of a curb. A real corner is one tangent-continuous
|
|
// sweep between the two outermost road edges, so fit a fixed-radius fillet
|
|
// into the wedge those edges form and fill the sector behind it.
|
|
const cornerFills = [];
|
|
const sidewalkCorners = [];
|
|
const cornerDiagnostics = [];
|
|
const cornerRadius = Math.max(4, Math.min(25, Number(cluster.cornerRadiusMeters) || 12));
|
|
for (let index = 0; index < arms.length; index += 1) {
|
|
const first = arms[index];
|
|
const second = arms[(index + 1) % arms.length];
|
|
const delta = positiveHeadingDelta(first.heading, second.heading);
|
|
if (delta < 45 || delta > 135) continue;
|
|
const bisector = normalizeHeading(first.heading + delta / 2);
|
|
const edges = [first, second].map((arm) => cornerEdgeAt(arm, coreRadius + 6, bisector, center, helpers));
|
|
if (!edges.every(Boolean)) continue;
|
|
const apex = rayIntersection(edges[0], edges[1], center);
|
|
const apexReach = apex ? directionalProjectionMeters(center, apex, bisector) : null;
|
|
// The wedge apex has to sit ahead of the core and inside the arm handoff;
|
|
// outside that band the two edges are near parallel and any fillet fitted
|
|
// to them would sweep across the carriageways instead of the corner.
|
|
if (apexReach === null || apexReach < 1 || apexReach > outerRadius) {
|
|
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center));
|
|
continue;
|
|
}
|
|
// Tangent distance for a circle of `cornerRadius` inscribed in a wedge of
|
|
// opening `delta`, clamped so the tangent points stay on the built arms.
|
|
const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach));
|
|
const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance));
|
|
const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS);
|
|
const ring = [...curve, center, curve[0]];
|
|
if (!ring.every((point) => point.every(Number.isFinite))) continue;
|
|
cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
|
|
|
// The straight pedestrian strips are trimmed at the cluster boundary, so
|
|
// two arms that both carry a footway still meet as two loose ends across
|
|
// an empty wedge. Bridge them along the curb the fillet already defines.
|
|
// The corner faces clockwise from `first` and counter-clockwise from
|
|
// `second`, so each arm must carry the footway on that facing side.
|
|
if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue;
|
|
const curb = [
|
|
...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(),
|
|
...curve.slice(1, -1),
|
|
...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers),
|
|
];
|
|
const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers);
|
|
const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]];
|
|
if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) {
|
|
cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center));
|
|
continue;
|
|
}
|
|
sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } });
|
|
}
|
|
const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second));
|
|
const coreRing = roundedPolygonRing(corePoints, .16);
|
|
const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }];
|
|
for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) {
|
|
const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)];
|
|
features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
|
}
|
|
// Corner fills come last so they overlay the rectangular carriageway ends
|
|
// they are smoothing; they never replace an OSM-derived road surface.
|
|
features.push(...cornerFills);
|
|
// `coreRadiusMeters` is only consulted when there is no reference geometry.
|
|
// Under calibration the radius comes from the reference span, so a configured
|
|
// value that silently does nothing has to be reported, not swallowed.
|
|
const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5;
|
|
const configurationDiagnostics = configuredRadiusIgnored
|
|
? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)]
|
|
: [];
|
|
return { features, islands: [...islands, ...sidewalkCorners], crosswalks, stopLines, center, approaches, diagnostics: [...cornerDiagnostics, ...configurationDiagnostics, helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], calibration ? "complex-junction-reference-calibrated" : "complex-junction-generated", calibration ? `已使用参考几何校准参数后,由 OSM/native 重新生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。` : `已独立生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。`, center)] };
|
|
}
|
|
|
|
function readReferenceCalibration(cluster) {
|
|
if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null;
|
|
try {
|
|
const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8")));
|
|
const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) });
|
|
const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180);
|
|
return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 };
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function complexJunctionMetrics(cluster) {
|
|
if (metricsCache.has(cluster)) return metricsCache.get(cluster);
|
|
const calibration = readReferenceCalibration(cluster);
|
|
const coreRadius = calibration
|
|
? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14))
|
|
: Math.max(11, Math.min(17, cluster.coreRadiusMeters * .52));
|
|
const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) };
|
|
metricsCache.set(cluster, metrics);
|
|
return metrics;
|
|
}
|
|
|
|
function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
|
|
|
|
// `arm.heading` points outward from the junction, so the corner clockwise from
|
|
// it sits at heading+90 and the one counter-clockwise at heading-90. A road's
|
|
// own sidewalk flags are relative to its digitisation direction, so flip them
|
|
// whenever the arm runs against that direction.
|
|
function armCarriesSidewalk(arm, model, cornerIsClockwise) {
|
|
return arm.members.some((member) => member.approach.roadIds
|
|
.map((roadId) => model.roads.find((road) => road.id === roadId))
|
|
.filter(Boolean)
|
|
.some((road) => {
|
|
const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId);
|
|
const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft;
|
|
const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight;
|
|
return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide);
|
|
}));
|
|
}
|
|
|
|
// Walk outward along a wedge edge from its tangent point until the curb reaches
|
|
// `targetRadius`, so the corner band overlaps the straight strip it joins.
|
|
function edgeRunToRadius(apex, edge, tangentDistance, targetRadius, center, helpers) {
|
|
const points = [];
|
|
for (let extra = 0; extra <= 40; extra += 2) {
|
|
const point = helpers.offsetCoordinate(apex, edge.heading, tangentDistance + extra);
|
|
points.push(point);
|
|
if (helpers.distanceMeters(point, center) >= targetRadius) break;
|
|
}
|
|
return points;
|
|
}
|
|
|
|
// Offset each vertex along the polyline normal that increases distance from the
|
|
// junction centre. The curb is star-shaped around that centre, so "farther from
|
|
// the centre" is a reliable stand-in for "on the pedestrian side".
|
|
function offsetPolylineAwayFromCenter(points, center, meters, helpers) {
|
|
return points.map((point, index) => {
|
|
const previous = points[Math.max(0, index - 1)];
|
|
const next = points[Math.min(points.length - 1, index + 1)];
|
|
const tangent = previous === next ? 0 : bearing(previous, next);
|
|
return [tangent + 90, tangent - 90]
|
|
.map((heading) => helpers.offsetCoordinate(point, heading, meters))
|
|
.sort((first, second) => helpers.distanceMeters(second, center) - helpers.distanceMeters(first, center))[0];
|
|
});
|
|
}
|
|
|
|
function ringSelfIntersects(ring) {
|
|
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
const straddles = (p1, p2, p3, p4) => {
|
|
const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2);
|
|
const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4);
|
|
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
|
|
};
|
|
for (let first = 0; first < ring.length - 1; first += 1) {
|
|
for (let second = first + 2; second < ring.length - 1; second += 1) {
|
|
if (first === 0 && second === ring.length - 2) continue;
|
|
if (straddles(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
|
|
function signedLateralMeters(origin, point, heading) {
|
|
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
|
|
const north = (point[1] - origin[1]) * 111320;
|
|
const radians = (heading + 90) * Math.PI / 180;
|
|
return east * Math.sin(radians) + north * Math.cos(radians);
|
|
}
|
|
function bearing(first, second) {
|
|
const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180);
|
|
const north = second[1] - first[1];
|
|
return Math.atan2(east, north) * 180 / Math.PI;
|
|
}
|
|
function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; }
|
|
function averageHeading(headings) {
|
|
const vector = headings.reduce((sum, heading) => {
|
|
const radians = heading * Math.PI / 180;
|
|
return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)];
|
|
}, [0, 0]);
|
|
return Math.atan2(vector[0], vector[1]) * 180 / Math.PI;
|
|
}
|
|
function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; }
|
|
function pointOnCarriagewayRadius(item, center, radius, helpers) {
|
|
const start = item.approach.line[0];
|
|
const startRadius = directionalProjectionMeters(center, start, item.heading);
|
|
return helpers.pointAlongLine(item.approach.line, Math.max(0, Math.min(item.length, radius - startRadius)));
|
|
}
|
|
function armEnvelopeAtRadius(arm, radius, center, helpers) {
|
|
if (!arm.members.length) return null;
|
|
const centers = arm.members.map((member) => pointOnCarriagewayRadius(member, center, radius, helpers));
|
|
const reference = centers[0];
|
|
let minimum = Infinity;
|
|
let maximum = -Infinity;
|
|
centers.forEach((point, index) => {
|
|
const lateral = signedLateralMeters(reference, point, arm.heading);
|
|
const halfWidth = arm.members[index].approach.widthMeters / 2;
|
|
minimum = Math.min(minimum, lateral - halfWidth);
|
|
maximum = Math.max(maximum, lateral + halfWidth);
|
|
});
|
|
if (!Number.isFinite(minimum) || maximum - minimum < 1) return null;
|
|
return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum };
|
|
}
|
|
function supportLineIntersection(first, second, origin) {
|
|
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
|
|
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
|
|
const firstPoint = toLocal(first.center);
|
|
const secondPoint = toLocal(second.center);
|
|
const direction = (heading) => {
|
|
const radians = heading * Math.PI / 180;
|
|
return [Math.sin(radians), Math.cos(radians)];
|
|
};
|
|
const firstDirection = direction(first.supportHeading);
|
|
const secondDirection = direction(second.supportHeading);
|
|
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
|
|
if (Math.abs(denominator) < 1e-6) return null;
|
|
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
|
|
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
|
|
const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
|
|
return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320];
|
|
}
|
|
function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) {
|
|
const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)];
|
|
if (!pair.every(Boolean)) return null;
|
|
const width = helpers.distanceMeters(pair[0], pair[1]);
|
|
const middle = midpoint(pair[0], pair[1]);
|
|
const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2);
|
|
const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90;
|
|
return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)];
|
|
}
|
|
function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) {
|
|
return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null;
|
|
}
|
|
function cornerEdgeAt(arm, radius, bisector, center, helpers) {
|
|
const candidates = arm.members.flatMap((member) => {
|
|
const point = pointOnCarriagewayRadius(member, center, radius, helpers);
|
|
const halfWidth = member.approach.widthMeters / 2;
|
|
return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading }));
|
|
});
|
|
return candidates.sort((first, second) => directionalProjectionMeters(center, second.point, bisector) - directionalProjectionMeters(center, first.point, bisector))[0] || null;
|
|
}
|
|
function rayIntersection(first, second, origin) {
|
|
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
|
|
const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
|
|
const direction = (heading) => {
|
|
const radians = heading * Math.PI / 180;
|
|
return [Math.sin(radians), Math.cos(radians)];
|
|
};
|
|
const firstPoint = toLocal(first.point);
|
|
const secondPoint = toLocal(second.point);
|
|
const firstDirection = direction(first.heading);
|
|
const secondDirection = direction(second.heading);
|
|
const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
|
|
if (Math.abs(denominator) < 1e-4) return null;
|
|
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
|
|
const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator;
|
|
const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst];
|
|
if (!local.every(Number.isFinite)) return null;
|
|
return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320];
|
|
}
|
|
function quadraticCurve(start, control, end, segments) {
|
|
const result = [];
|
|
for (let index = 0; index <= segments; index += 1) {
|
|
const t = index / segments;
|
|
const u = 1 - t;
|
|
result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]);
|
|
}
|
|
return result;
|
|
}
|
|
function directionalProjectionMeters(origin, point, heading) {
|
|
const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
|
|
const north = (point[1] - origin[1]) * 111320;
|
|
const radians = heading * Math.PI / 180;
|
|
return east * Math.sin(radians) + north * Math.cos(radians);
|
|
}
|
|
function smoothClosedRing(vertices) {
|
|
// Curb-edge candidates can arrive in opposite winding orders when an OSM
|
|
// carriageway bends slightly. Sort this local corner only around its own
|
|
// centroid before rounding, avoiding a self-crossing safety island while
|
|
// keeping the global junction boundary fully OSM-driven.
|
|
const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
|
|
const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0]));
|
|
const points = ordered.flatMap((point, index) => {
|
|
const next = ordered[(index + 1) % ordered.length];
|
|
return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)];
|
|
});
|
|
return [...points, points[0]];
|
|
}
|
|
function roundedPolygonRing(vertices, ratio) {
|
|
const points = vertices.flatMap((point, index) => {
|
|
const previous = vertices[(index - 1 + vertices.length) % vertices.length];
|
|
const next = vertices[(index + 1) % vertices.length];
|
|
return [interpolateCoordinate(previous, point, 1 - ratio), interpolateCoordinate(point, next, ratio)];
|
|
});
|
|
return [...points, points[0]];
|
|
}
|
|
function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; }
|
|
|
|
module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };
|