Files
osmWorkflow/scripts/lib/native-road.js
que01 12aeda9a63 feat: parameterize complex junction geometry with Gaode reference
- 高德 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=[]。
2026-08-21 11:59:03 +08:00

1486 lines
105 KiB
JavaScript

"use strict";
const fs = require("fs");
const path = require("path");
const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows");
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require("./complex-junction");
const OVERRIDE_SCHEMA = "native-road-overrides/v1";
const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]);
const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 };
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
const DIRECTION_ARROW_INTERVAL_METERS = 32;
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
const STOP_LINE_OFFSET_METERS = 2.7;
const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25;
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4;
const CENTER_LINE_DASH_LENGTH_METERS = 2;
const CENTER_LINE_DASH_GAP_METERS = 2;
const CENTER_LINE_WIDTH_METERS = .25;
const CENTER_LINE_SOLID_OVERLAP_METERS = .04;
const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
const CENTER_LINE_COLORS = new Set(["yellow", "white"]);
const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]);
const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .05;
// A lane centerline is drawn as a hairline, so probe the crossing with a narrow
// band. Using the full lane width would clip the line metres early.
const LANE_CENTERLINE_PROBE_WIDTH_METERS = .12;
const JUNCTION_CURVE_SEGMENTS = 8;
function parseOsmRoads(xml) {
const nodes = new Map();
const crossingNodes = [];
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
const attrs = xmlAttrs(match[1]);
if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
if (!coordinate.every(Number.isFinite)) continue;
const id = String(attrs.id); const tags = parseTags(match[2] || "");
nodes.set(id, coordinate);
if (tags.highway === "crossing" && !["no", "none", "unmarked"].includes(tags["crossing:markings"])) crossingNodes.push({ id, coordinate, tags });
}
const ways = [];
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
const attrs = xmlAttrs(match[1]);
const body = match[2];
const tags = parseTags(body);
if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue;
const refs = [...body.matchAll(/<nd\b([^>]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean);
const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean);
if (coords.length < 2 || coords.length !== refs.length) continue;
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
}
return { nodes, ways, crossingNodes };
}
function compileRoadModel(xml, overrides) {
const parsed = parseOsmRoads(xml);
const diagnostics = [];
const roads = [];
const endpoints = [];
const byNode = new Map();
const sharedNodeWayIds = new Map();
for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) {
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
sharedNodeWayIds.get(nodeId).add(way.id);
}
for (const sourceWay of parsed.ways) {
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
for (const way of segments) {
const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"];
for (const direction of directions) {
const base = roadAttributes(way.tags, direction);
const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`;
const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] };
applyRoadOverrides(road, overrides, diagnostics);
roads.push(road);
for (const side of ["start", "end"]) {
const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1];
const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction };
endpoints.push(endpoint);
if (!byNode.has(nodeId)) byNode.set(nodeId, []);
byNode.get(nodeId).push(endpoint);
}
}
}
}
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
const extent = roadExtent(roads);
for (const [nodeId, items] of byNode) {
if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) {
const endpoint = items[0];
diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id, manualCandidates: nearbyManualCandidates(endpoints, endpoint) });
}
}
const crossings = parsed.crossingNodes.map((crossing) => ({ ...crossing, osmWayIds: parsed.ways.filter((way) => way.refs.includes(crossing.id)).map((way) => way.id) }));
return { schema: "native-road-model/v1", roads, endpoints, connections, crossings, diagnostics };
}
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
const splitIndexes = [0];
for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
splitIndexes.push(way.refs.length - 1);
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
return splitIndexes.slice(1).map((end, index) => {
const start = splitIndexes[index];
return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 };
});
}
function roadExtent(roads) {
const points = roads.flatMap((road) => road.centerline);
return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) };
}
function distanceToExtentEdgeMeters(point, extent) {
const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180);
return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320);
}
function roadAttributes(tags, direction) {
const directional = direction === "forward" ? "forward" : "backward";
const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null);
const parsedLanes = positiveInteger(laneTag);
const totalLanes = positiveInteger(tags.lanes);
const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1);
const parsedWidth = positiveNumber(tags.width);
const forwardLanes = positiveInteger(tags["lanes:forward"]);
const backwardLanes = positiveInteger(tags["lanes:backward"]);
const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes;
// `width` describes the whole OSM way. A directional road receives its lane
// share; absent width falls back to a realistic per-lane carriageway width.
const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25;
return {
laneCount: lanes,
widthMeters: width,
sidewalkLeft: sidewalkState(tags, direction, "left"),
sidewalkRight: sidewalkState(tags, direction, "right"),
provenance: {
laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes",
widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane",
},
};
}
function sidewalkState(tags, direction, side) {
const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left";
const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk;
return value === "both" || value === "yes" || value === osmSide;
}
function loadOverrides(file) {
if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] };
return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8")));
}
function validateOverrides(value, model) {
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
const ids = new Set();
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
const directionalRoadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null;
for (const item of value.overrides) {
if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id.");
ids.add(item.id);
if (item.kind === "road") {
if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`);
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`);
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
} else if (item.kind === "junction-connection") {
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m).");
} else if (item.kind === "lane-connection") {
if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override.");
} else if (item.kind === "center-line-style") {
if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (item.double !== undefined && typeof item.double !== "boolean") || (item.double && (item.color !== "yellow" || item.pattern !== "solid")) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override.");
} else if (item.kind === "lane-separator-style") {
if (typeof item.roadId !== "string" || (roadIds && !roadIds.has(item.roadId)) || !Number.isInteger(item.leftLaneIndex) || item.rightLaneIndex !== item.leftLaneIndex + 1 || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid lane separator style override.");
} else if (item.kind === "edge-line-style") {
if (typeof item.roadId !== "string" || (directionalRoadIds && !directionalRoadIds.has(item.roadId)) || !["left", "right"].includes(item.side) || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid edge line style override.");
} else throw new Error(`Unsupported override kind: ${item.kind}`);
}
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
}
function applyRoadOverrides(road, overrides, diagnostics) {
const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id));
// A legacy whole-way edit remains the baseline; a segment-specific edit can
// deliberately refine it after the compiler has introduced split segments.
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
for (const item of matching) {
for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key];
road.appliedOverrideIds.push(item.id);
for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
}
if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0]));
}
function resolveConnections(endpoints, byNode, overrides, diagnostics) {
const result = [];
for (const [nodeId, items] of byNode) {
const arrivals = items.filter((endpoint) => endpoint.side === "end");
const departures = items.filter((endpoint) => endpoint.side === "start");
for (const arrival of arrivals) for (const departure of departures) {
if (arrival.roadId === departure.roadId) continue;
const arrivalRoad = endpoints.find((endpoint) => endpoint.id === arrival.id)?.roadId;
const departureRoad = endpoints.find((endpoint) => endpoint.id === departure.id)?.roadId;
if (sameOsmWay(endpoints, arrivalRoad, departureRoad)) continue;
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
}
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
}
// Overrides can add a deliberate movement omitted by the initial inference.
// Keep it only when both endpoints still belong to the same OSM junction.
for (const override of overrides.overrides.filter((item) => item.kind === "junction-connection")) {
const exists = result.some((connection) => connection.fromEndpointId === override.fromEndpointId && connection.toEndpointId === override.toEndpointId);
if (exists) continue;
const from = endpoints.find((endpoint) => endpoint.id === override.fromEndpointId);
const to = endpoints.find((endpoint) => endpoint.id === override.toEndpointId);
if (!from || !to || !connectionEndpointsCompatible({ endpoints }, from.id, to.id)) continue;
result.push({ id: `connection:${from.id}:${to.id}`, nodeId: from.nodeId, fromEndpointId: from.id, toEndpointId: to.id, enabled: override.enabled, provenance: `override:${override.id}` });
}
return result;
}
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, "");
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
}
function connectionEndpointsCompatible(model, fromId, toId) {
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
const to = model.endpoints.find((endpoint) => endpoint.id === toId);
if (!from || !to || from.roadId === to.roadId || sameOsmWay(model.endpoints, from.roadId, to.roadId) || from.side !== "end" || to.side !== "start") return false;
if (from.nodeId === to.nodeId) return true;
const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180);
const dy = (from.coordinate[1] - to.coordinate[1]) * 111320;
return Math.hypot(dx, dy) <= 35;
}
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 }));
}
function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model, 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])));
const complexClusterCenters = new Map(activeClusters.filter((cluster) => cluster.template === "complex-junction-v1").map((cluster) => {
const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean);
const center = points.length ? points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]) : null;
return [cluster.id, center];
}));
const emittedSegments = new Set();
const generatedComplexSidewalks = [];
const generatedComplexCrosswalks = [];
const generatedComplexStopLines = [];
for (const road of model.roads) {
const segmentKey = road.segmentId;
if (emittedSegments.has(segmentKey)) continue;
emittedSegments.add(segmentKey);
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
const startCluster = clusterByNode.get(String(road.sourceNodeIds[0]));
const endCluster = clusterByNode.get(String(road.sourceNodeIds.at(-1)));
if (startCluster?.template === "complex-junction-v1" && endCluster?.template === "complex-junction-v1" && startCluster.id === endCluster.id) continue;
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
// The approach surface stops at the junction cutback. The junction layer
// owns the intervening rounded corners; leaving approaches untrimmed
// would cover that outline with rectangular road ends in Blender/Cesium.
const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
if (cluster?.template === "complex-junction-v1") {
const center = complexClusterCenters.get(cluster.id);
const length = lineLengthMeters(road.centerline);
const farEndpoint = cluster.nodeIds.map(String).includes(String(road.sourceNodeIds[0])) ? road.centerline.at(-1) : road.centerline[0];
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
if (center && length < outerRadius + 8 && distanceMeters(farEndpoint, center) < outerRadius) continue;
}
const line = cluster?.template === "complex-junction-v1"
? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, cluster, complexClusterCenters.get(cluster.id))
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
const ring = roadRing(line, totalWidth);
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`;
features.push({ type: "Feature", properties: { native_id: surfaceId, cluster_id: cluster?.template === "complex-junction-v1" ? cluster.id : null, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
for (const [nodeId, plan] of junctionPlans) {
if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue;
if (!plan.template) continue;
for (const approach of plan.approaches) {
const transition = templateApproachRing(approach, plan);
if (!transition) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-approach-fallback", "进口道路长度不足以生成规整过渡面,已保留该进口的 native 直筒道路。", plan.node));
continue;
}
features.push({ type: "Feature", properties: { native_id: `junction-approach:${plan.template}:node/${nodeId}:${approach.segmentId}`, osm_node_id: nodeId, segment_id: approach.segmentId, directional_road_ids: approach.roadIds.join(","), width_m: approach.widthMeters, approach_width_m: Math.round(approach.widthMeters * plan.approachWidthMultiplier * 10) / 10, approach_length_m: Math.round(transition.lengthMeters * 10) / 10, template: plan.template, provenance: "native-road-junction-approach-template/v1" }, geometry: { type: "Polygon", coordinates: [transition.ring] } });
}
}
for (const cluster of activeClusters) {
if (cluster.template !== "complex-junction-v1") continue;
const generated = buildComplexJunctionGeometry(model, cluster, { junctionPlans, diagnostic, distanceMeters, lineLengthMeters, pointAlongLine, offsetCoordinate, headingAtEndpoint, headingVector, circleRing });
features.push(...generated.features);
if (generated.crosswalks) generatedComplexCrosswalks.push(...generated.crosswalks);
if (generated.stopLines) generatedComplexStopLines.push(...generated.stopLines);
if (generated.islands) generatedComplexSidewalks.push(...generated.islands);
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 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);
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, allControls, options);
markings.separators.push(...compileComplexLaneSeparators(lanes.features, [...allControls.crosswalks, ...allControls.stopLines]));
markings.directionArrows.push(...compileComplexPreviewArrows(lanes.features, generatedComplexStopLines));
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans, options);
// Complex crosswalks are generated from native approach tangents; do not
// synthesize side strips that can be mistaken for crosswalks.
sidewalks.push(...generatedComplexSidewalks);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics, options);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: [...controls.crosswalks, ...generatedComplexCrosswalks] }, vehicleStopLines: { type: "FeatureCollection", features: [...controls.stopLines, ...generatedComplexStopLines] }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
}
function compileComplexPreviewArrows(features, stopLines) {
const result = [];
for (const feature of features) {
if (!feature.properties?.cluster_preview || !feature.properties.incoming || feature.properties.maneuver === "outbound") continue;
const line = feature.geometry?.coordinates || [];
if (line.length < 2) continue;
const stopLine = stopLines.find((candidate) => candidate.properties?.road_id === feature.properties.road_id);
if (!stopLine) continue;
const stopRing = stopLine.geometry?.coordinates?.[0];
if (!stopRing || stopRing.length < 4) continue;
const stopPoints = stopRing.slice(0, -1);
const stopCenter = [stopPoints.reduce((sum, point) => sum + point[0], 0) / stopPoints.length, stopPoints.reduce((sum, point) => sum + point[1], 0) / stopPoints.length];
const placement = distanceMeters(line.at(-1), stopCenter) + 8;
const placementInfo = pointAndAxisAlongLine(line, Math.max(0, lineLengthMeters(line) - placement));
if (!placementInfo) continue;
const rings = arrowRingsAt(feature.properties.maneuver, placementInfo.point, placementInfo.axis);
for (let part = 0; part < rings.length; part += 1) result.push({
type: "Feature",
properties: { native_id: `${feature.properties.native_id}:arrow:${part}`, road_id: feature.properties.road_id, lane_id: feature.properties.native_id, cluster_id: feature.properties.cluster_id, cluster_preview: true, maneuver: feature.properties.maneuver, travel_heading_deg: headingDegrees(line[0], line.at(-1)), placement_distance_from_stop_meters: 8, provenance: "native-road-complex-preview-arrow/v2-stop-anchored" },
geometry: { type: "Polygon", coordinates: [rings[part]] },
});
}
return result;
}
function compileComplexLaneSeparators(features, controls = []) {
const groups = new Map();
for (const feature of features) {
if (!feature.properties?.cluster_preview || !feature.properties.road_id) continue;
if (!groups.has(feature.properties.road_id)) groups.set(feature.properties.road_id, []);
groups.get(feature.properties.road_id).push(feature);
}
const result = [];
for (const [roadId, lanes] of groups) {
lanes.sort((first, second) => first.properties.lane_index - second.properties.lane_index);
for (let index = 1; index < lanes.length; index += 1) {
const left = lanes[index - 1].geometry.coordinates; const right = lanes[index].geometry.coordinates;
if (left.length !== right.length) continue;
const line = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const visibleLine = trimLineBeforeFirstControl(line, controls, .12);
const ring = visibleLine ? roadRing(visibleLine, .12) : null;
if (!ring) continue;
result.push({ type: "Feature", properties: { native_id: `complex-lane-separator:${roadId}:${index}-${index + 1}`, road_id: roadId, cluster_id: lanes[0].properties.cluster_id, left_lane_index: index, right_lane_index: index + 1, color: "white", pattern: "solid", effective_style: "white-solid", provenance: "native-road-complex-lane-separator/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
return result;
}
function trimLineBeforeFirstControl(line, controls, width) {
const total = lineLengthMeters(line);
const step = .25;
for (let distance = step; distance <= total; distance += step) {
const placement = pointAndAxisAlongLine(line, Math.min(total, distance - step / 2));
if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], step, width, 0);
if (!ringsOverlapControl([ring], controls)) continue;
let cutoff = Math.max(0, distance - step - .12);
while (cutoff > .5) {
const candidate = roadRing([line[0], pointAlongLine(line, cutoff)], width);
if (candidate && !ringsOverlapControl([candidate], controls)) return [line[0], pointAlongLine(line, cutoff)];
cutoff -= .25;
}
return null;
}
return line;
}
// `trimLineBeforeFirstControl` always keeps the head of the line, so the caller
// must hand it a line that already runs from the road towards the junction.
// Lane centerlines arrive in either orientation (a preview lane runs inward
// from the outer radius, an outgoing road lane runs outward from the cluster
// node), so orient by radius first and restore the original order afterwards.
function trimLaneOutsideControls(line, controls, width, center) {
if (!controls.length || !center || !Array.isArray(line) || line.length < 2) return line;
const outwardFirst = distanceMeters(line[0], center) >= distanceMeters(line.at(-1), center);
const oriented = outwardFirst ? line : [...line].reverse();
const trimmed = trimLineBeforeFirstControl(oriented, controls, width);
if (!trimmed) return null;
return outwardFirst ? trimmed : [...trimmed].reverse();
}
// Reference identity is not reliable here: the reversed path rebuilds the array
// even when nothing was cut. Compare travelled length instead.
function laneWasClipped(original, visible) {
return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - .01;
}
function compileEdgeLines(model, overrides, junctionPlans) {
const features = [];
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);
// 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.
const offsets = bidirectional ? [-1] : [-1, 1];
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);
if (style.pattern === "solid") {
const ring = roadRing(centerline, .12);
if (ring) features.push(edgeLineFeature(road, side, style, ring));
continue;
}
for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) {
const placement = pointAndAxisAlongLine(centerline, distance);
if (!placement) continue;
features.push(edgeLineFeature(road, side, style, rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0), part));
}
}
}
return features;
}
function edgeLineFeature(road, side, style, ring, part = null) {
return { type: "Feature", properties: { native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ""}`, road_id: road.id, side, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-edge-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } };
}
function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics, options = {}) {
const features = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
const segments = new Map();
for (const road of model.roads) {
if (!segments.has(road.segmentId)) segments.set(road.segmentId, []);
segments.get(road.segmentId).push(road);
}
for (const [segmentId, roads] of segments) {
const forward = roads.find((road) => road.direction === "forward");
const backward = roads.find((road) => road.direction === "backward");
if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue;
const cluster = clusterForRoad(forward, options) || clusterForRoad(backward, options);
const internalCluster = cluster && roadInternalToCluster(forward, cluster);
const line = cluster
? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, clusterCenter(cluster, junctionPlans))
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const length = lineLengthMeters(line);
if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; }
const style = centerLineStyle(overrides, segmentId);
const visibleLine = trimLineBeforeFirstControl(line, controlFeatures, CENTER_LINE_WIDTH_METERS);
const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0;
if (!visibleLine || visibleLength < CENTER_LINE_DASH_LENGTH_METERS) continue;
const gap = style.pattern === "solid" ? 0 : CENTER_LINE_DASH_GAP_METERS;
const markLength = CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === "solid" ? CENTER_LINE_SOLID_OVERLAP_METERS : 0);
for (let start = 0, dashIndex = 1; start + markLength <= visibleLength; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) {
const placement = pointAndAxisAlongLine(visibleLine, start + markLength / 2);
if (!placement) continue;
const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0);
for (const offset of style.double ? [-.16, .16] : [0]) { const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, offset); features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, double: Boolean(style.double), effective_style: `${style.double ? "double-" : ""}${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); }
}
}
return features;
}
function centerLineStyle(overrides, segmentId) {
const override = overrides.overrides.find((item) => item.kind === "center-line-style" && item.segmentId === segmentId);
return override ? { color: override.color, pattern: override.pattern, double: Boolean(override.double) } : { color: "yellow", pattern: "dashed", double: false };
}
function edgeLineStyle(overrides, roadId, side) {
const value = overrides.overrides.find((item) => item.kind === "edge-line-style" && item.roadId === roadId && item.side === side);
return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "solid" };
}
function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new Map()) {
const crosswalks = []; const stopLines = [];
const arrivalEndpointIds = new Set(model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId));
for (const crossing of model.crossings || []) {
const candidates = model.roads.filter((road) => crossing.osmWayIds.includes(road.osmWayIds[0])).flatMap((road) => (lanes.byRoadId.get(road.id) || []).map((lane) => ({ road, lane, placement: nearestLanePlacement(lane.coordinates, crossing.coordinate), junctionDistanceMeters: distanceMeters(crossing.coordinate, road.centerline.at(-1)) })).filter((item) => item.placement));
const candidate = candidates.sort((a, b) => a.placement.distance - b.placement.distance)[0];
if (!candidate || candidate.placement.distance > 12) { diagnostics.push(diagnostic("warning", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-native-lane", "人行横道无法匹配到安全的原生车道,未生成标线。", crossing.coordinate)); continue; }
const approach = candidates.filter((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`) && item.junctionDistanceMeters > STOP_LINE_OFFSET_METERS && item.junctionDistanceMeters <= STOP_LINE_MAX_APPROACH_DISTANCE_METERS).sort((a, b) => a.junctionDistanceMeters - b.junctionDistanceMeters || a.placement.distance - b.placement.distance)[0];
const crosswalkCandidate = approach || candidate;
const junctionInsetMeters = approach ? crossingJunctionInset(approach, junctionPlans) : 0;
const controlCenter = offsetByMeters(crossing.coordinate, crosswalkCandidate.placement.axis, junctionInsetMeters);
const { axis } = crosswalkCandidate.placement; const across = [-axis[1], axis[0]];
for (let index = 0; index < 6; index += 1) crosswalks.push(controlFeature("crosswalk", crossing, crosswalkCandidate, index + 1, rectangleAt(controlCenter, axis, across, 3, .45, -2.25 + index * .9), { junctionInsetMeters }));
if (!approach) { diagnostics.push(diagnostic("info", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-safe-stop-line", "人行横道没有可确认的路口进口车道,保留斑马线但未生成停止线。", crossing.coordinate)); continue; }
const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, controlCenter);
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset);
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS);
stopLines.push(controlFeature("stop-line", crossing, approach, 1, rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, .45, 0), { junctionInsetMeters }));
}
return { crosswalks, stopLines };
}
function crossingJunctionInset(candidate, junctionPlans) {
const junctionNodeId = candidate.road.sourceNodeIds.at(-1);
const plan = junctionPlans.get(junctionNodeId);
if (!plan) return 0;
const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS);
return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance));
}
function nearestLanePlacement(line, target) { let best = null; let traversedMeters = 0; for (let index = 1; index < line.length; index += 1) { const a = line[index - 1]; const b = line[index]; const vector = project(b, a); const length = Math.hypot(...vector); if (!length) continue; const relative = project(target, a); const ratio = Math.max(0, Math.min(1, (relative[0] * vector[0] + relative[1] * vector[1]) / (length * length))); const point = interpolate(a, b, ratio); const distance = distanceMeters(point, target); if (!best || distance < best.distance) best = { point, axis: [vector[0] / length, vector[1] / length], distance, distanceToEndMeters: lineLengthMeters(line) - traversedMeters - length * ratio }; traversedMeters += length; } return best; }
function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meters, axis[1] * meters], point); }
function rectangleAt(center, axis, across, length, width, offset) { const shifted = offsetByMeters(center, across, offset); const corners = [[-length / 2, -width / 2], [length / 2, -width / 2], [length / 2, width / 2], [-length / 2, width / 2]].map(([forward, side]) => unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted)); return [...corners, corners[0]]; }
function controlFeature(kind, crossing, candidate, part, ring, placement = {}) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100, provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; }
function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls, options = {}) {
const separators = []; const directionArrows = []; const turnArrows = [];
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
for (const road of model.roads) {
const cluster = clusterForRoad(road, options);
const internalCluster = cluster && roadInternalToCluster(road, cluster);
const roadLanes = (lanes.markingByRoadId || lanes.byRoadId).get(road.id) || [];
for (let index = 1; index < roadLanes.length; index += 1) {
const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates;
if (left.length !== right.length) continue;
const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]);
const style = laneSeparatorStyle(overrides, road.id, index, index + 1);
const properties = { road_id: road.id, left_lane_index: index, right_lane_index: index + 1, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" };
if (style.pattern === "solid") {
const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, .12);
const ring = visibleLine ? roadRing(visibleLine, .12) : null;
if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } });
} else {
const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, .12);
const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0;
for (let distance = 1, part = 1; visibleLine && distance + 1 <= visibleLength; distance += 4, part += 1) {
const placement = pointAndAxisAlongLine(visibleLine, distance);
if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0);
separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
}
for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics).map((feature) => ({ ...feature, properties: { ...feature.properties, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster) } })));
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
const maneuvers = turns ? String(turns).split("|") : [];
for (let index = 0; index < roadLanes.length; index += 1) {
const lane = roadLanes[index]; const explicitManeuver = maneuvers[index];
if (!explicitManeuver) continue;
const maneuver = normalizeManeuver(explicitManeuver);
if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; }
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; }
if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; }
const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1);
const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]);
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
const placement = axis ? [6, 10, 14, 18, 22].find((distance) => distance < lineLengthMeters(lane.coordinates) - 2 && !ringsOverlapControl(arrowRingsAt(maneuver, pointAlongLine([...lane.coordinates].reverse(), distance), axis), controlFeatures)) : null;
if (!placement) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-control-conflict", "转向箭头会压住斑马线或停止线,未生成该箭头。", lane.coordinates.at(-1))); continue; }
const center = pointAlongLine([...lane.coordinates].reverse(), placement);
const rings = arrowRingsAt(maneuver, center, axis);
if (!rings.length) continue;
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: placement, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
}
return { separators, directionArrows, turnArrows };
}
function clusterForRoad(road, options) {
const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
return clusters.find((cluster) => cluster.template === "complex-junction-v1" && road.sourceNodeIds.some((nodeId) => cluster.nodeIds.map(String).includes(String(nodeId)))) || null;
}
function roadInternalToCluster(road, cluster) {
const nodeIds = new Set(cluster.nodeIds.map(String));
return nodeIds.has(String(road.sourceNodeIds[0])) && nodeIds.has(String(road.sourceNodeIds.at(-1)));
}
function clusterCenter(cluster, junctionPlans) {
const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean);
return points.length ? points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]) : null;
}
function angularDistance(first, second) {
return Math.abs(((first - second + 180) % 360) - 180);
}
function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) { const value = overrides.overrides.find((item) => item.kind === "lane-separator-style" && item.roadId === roadId && item.leftLaneIndex === leftLaneIndex && item.rightLaneIndex === rightLaneIndex); return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "dashed" }; }
function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
const length = lineLengthMeters(lane.coordinates);
const features = [];
for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) {
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
if (!placement) continue;
const rings = arrowRingsAt("through", placement.point, placement.axis);
if (ringsOverlapControl(rings, controlFeatures)) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "direction-arrow-control-conflict", "默认直行箭头会压住斑马线或停止线,已跳过该位置。", placement.point)); continue; }
for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
}
return features;
}
function ringsOverlapControl(rings, controls) {
return rings.some((ring) => controls.some((feature) => ringsOverlap(ring, feature.geometry.coordinates[0])));
}
function ringsOverlap(first, second) {
const bounds = (ring) => [Math.min(...ring.map((point) => point[0])), Math.min(...ring.map((point) => point[1])), Math.max(...ring.map((point) => point[0])), Math.max(...ring.map((point) => point[1]))];
const a = bounds(first); const b = bounds(second);
if (a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]) return false;
if (first.some((point) => pointInPolygon(point, second)) || second.some((point) => pointInPolygon(point, first))) return true;
return first.slice(1).some((point, index) => second.slice(1).some((other, otherIndex) => segmentsIntersect(first[index], point, second[otherIndex], other)));
}
function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}) {
const features = [];
const byWay = new Map();
for (const road of model.roads) {
const key = road.segmentId;
if (!byWay.has(key)) byWay.set(key, []);
byWay.get(key).push(road);
}
for (const [wayKey, directions] of byWay) {
const forward = directions.find((road) => road.direction === "forward") || directions[0];
const backward = directions.find((road) => road.id !== forward.id);
const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0);
const sides = [
["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
];
const cluster = clusterForRoad(forward, options) || (backward ? clusterForRoad(backward, options) : null);
const center = cluster ? clusterCenter(cluster, junctionPlans) : null;
for (const [side, enabled] of sides) {
if (!enabled) continue;
const centerline = cluster
? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, center)
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1);
if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; }
const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`;
features.push({ type: "Feature", properties: { native_id: sidewalkId, cluster_id: cluster?.id || null, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
}
features.push(...compileSidewalkCorners(model, junctionPlans, options));
return features;
}
function compileSidewalkCorners(model, junctionPlans, options = {}) {
const result = [];
for (const [nodeId, plan] of junctionPlans) {
if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue;
const candidates = [];
for (const approach of plan.approaches) {
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
const forward = directions.find((road) => road.direction === "forward") || directions[0];
if (!forward) continue;
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
const sideStates = outwardIsForward
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
if (!cutback) continue;
const heading = headingAtEndpoint(approach.line);
const halfWidth = approach.widthMeters / 2;
for (const [side, enabled] of Object.entries(sideStates)) {
if (!enabled) continue;
// offsetLine's positive normal is driver's left, which is heading -90
// in this north-based heading convention.
const sideHeading = heading + (side === "left" ? -90 : 90);
candidates.push({
wayKey: approach.segmentId,
sourceWayKey: forward.osmWayIds.join(","),
side,
outwardHeading: heading,
normalDegrees: sideHeading,
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
});
}
}
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
for (let index = 0; index < candidates.length; index += 1) {
const first = candidates[index];
const second = candidates[(index + 1) % candidates.length];
if (first.wayKey === second.wayKey) continue;
const continuation = isStraightSidewalkContinuation(first, second);
if (first.sourceWayKey === second.sourceWayKey && !continuation) continue;
// A split-through road has two approaches at this node. Its pedestrian
// strip is a direct continuation, not a curb corner. Treating it as a
// curve creates the oversized outer lobe seen at T junctions.
const ring = continuation
? [first.curb, first.outer, second.outer, second.curb, first.curb]
: roundedSidewalkCorner(plan.node, first, second);
if (hasSelfIntersection(ring)) continue;
if (continuation && cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches)) continue;
result.push({
type: "Feature",
properties: {
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
osm_node_id: nodeId,
kind: continuation ? "continuation" : "corner",
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
provenance: continuation ? "native-road-sidewalk-continuation/v1" : "native-road-sidewalk-corner/v1",
},
geometry: { type: "Polygon", coordinates: [ring] },
});
}
}
return result;
}
function roundedSidewalkCorner(node, first, second) {
// Keep the established vehicle curb geometry, then derive the outer edge
// from it. Independent Bezier curves drift apart and leave asphalt exposed
// between the junction and pedestrian layers.
const curbForward = roundedCorner(node, first.curb, second.curb, first.outwardHeading, second.outwardHeading) || [first.curb, second.curb];
// Construct the outside edge from the same tangent-support rule. A linear
// point-by-point offset changes the curvature and makes the two boundaries
// visibly disagree at the middle of the corner.
const outerForward = roundedCorner(node, first.outer, second.outer, first.outwardHeading, second.outwardHeading)
|| offsetCornerArc(curbForward, first.curb, first.outer, second.curb, second.outer);
const curbArc = [...curbForward].reverse();
return [
first.curb,
first.outer,
...outerForward.slice(1, -1),
second.outer,
second.curb,
...curbArc.slice(1, -1),
first.curb,
];
}
function offsetCornerArc(curbArc, firstCurb, firstOuter, secondCurb, secondOuter) {
return curbArc.map((point, index) => {
const ratio = curbArc.length === 1 ? 0 : index / (curbArc.length - 1);
const firstOffset = [firstOuter[0] - firstCurb[0], firstOuter[1] - firstCurb[1]];
const secondOffset = [secondOuter[0] - secondCurb[0], secondOuter[1] - secondCurb[1]];
return [point[0] + firstOffset[0] + (secondOffset[0] - firstOffset[0]) * ratio, point[1] + firstOffset[1] + (secondOffset[1] - firstOffset[1]) * ratio];
});
}
function samePhysicalSide(first, second) {
const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180;
return Math.cos(radians) >= 0.98;
}
function isStraightSidewalkContinuation(first, second) {
if (first.sourceWayKey !== second.sourceWayKey || !samePhysicalSide(first, second)) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98;
}
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
const vertices = ring.slice(0, -1);
const center = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]);
return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => {
const carriageway = roadRing(approach.line, approach.widthMeters);
return carriageway && pointInPolygon(center, carriageway);
});
}
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
const junctionByNode = new Map();
for (const feature of junctionFeatures) {
if (feature.properties.osm_node_ids) for (const nodeId of String(feature.properties.osm_node_ids).split(",")) junctionByNode.set(nodeId, feature);
else if (feature.properties.osm_node_id) junctionByNode.set(feature.properties.osm_node_id, feature);
}
for (const connector of connectors) {
const junction = junctionByNode.get(connector.properties.node_id);
if (!junction) continue;
if (junction.properties.kind === "cluster") continue;
const ring = junction.geometry.coordinates[0];
if (!connector.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS))) {
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
}
}
}
function pointInPolygon(point, ring) {
for (let index = 1; index < ring.length; index += 1) if (pointOnSegment(point, ring[index - 1], ring[index])) return true;
let inside = false;
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
const a = ring[index]; const b = ring[previous];
const intersect = a[1] > point[1] !== b[1] > point[1] && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0];
if (intersect) inside = !inside;
}
return inside;
}
function pointInOrNearPolygon(point, ring, toleranceMeters) {
return pointInPolygon(point, ring) || ring.slice(1).some((end, index) => distancePointToSegmentMeters(point, ring[index], end) <= toleranceMeters);
}
function distancePointToSegmentMeters(point, start, end) {
const localPoint = project(point, start);
const localEnd = project(end, start);
const lengthSquared = localEnd[0] ** 2 + localEnd[1] ** 2;
if (lengthSquared < .0001) return Math.hypot(...localPoint);
const ratio = Math.max(0, Math.min(1, (localPoint[0] * localEnd[0] + localPoint[1] * localEnd[1]) / lengthSquared));
return Math.hypot(localPoint[0] - localEnd[0] * ratio, localPoint[1] - localEnd[1] * ratio);
}
function pointOnSegment(point, a, b) {
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
if (Math.abs(cross) > 1e-12) return false;
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
}
// `complexControls` carries the crosswalks and stop bars the complex-junction
// templates already emitted. Ordinary controls cannot be passed here: they are
// placed *from* these lane centerlines, so only the template-generated ones
// exist this early.
function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {}, complexControls = {}) {
const features = [];
const controlFeatures = [...(complexControls.crosswalks || []), ...(complexControls.stopLines || [])];
const byRoadId = new Map();
const markingByRoadId = new Map();
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)]));
for (const road of model.roads) {
const lanes = [];
const markingLanes = [];
const laneWidth = road.widthMeters / road.laneCount;
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
const opposite = siblings.find((item) => item.id !== road.id);
const boundaryCluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
const internalCluster = boundaryCluster && roadInternalToCluster(road, boundaryCluster);
const clippedRoadLine = boundaryCluster?.template === "complex-junction-v1"
? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, boundaryCluster, clusterCenters.get(boundaryCluster.id))
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
// OSM centerline is the shared carriageway center. On a two-way road,
// offset each directed carriageway to its own side before placing lanes.
const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0;
for (let index = 0; index < road.laneCount; index += 1) {
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
// driver's left so tag positions and generated lane IDs have one meaning.
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
const publishedCoordinates = offsetLine(clippedRoadLine, offset);
if (!coordinates || !publishedCoordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
lanes.push(lane);
// Only the published geometry stops at the crossing. `coordinates` stays
// whole because connectors are derived from it; a lane that ends at the
// stop bar would otherwise break every turn path through the junction.
const visibleCoordinates = boundaryCluster?.template === "complex-junction-v1"
? trimLaneOutsideControls(publishedCoordinates, controlFeatures, LANE_CENTERLINE_PROBE_WIDTH_METERS, clusterCenters.get(boundaryCluster.id))
: publishedCoordinates;
if (!visibleCoordinates) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "lane-centerline-fully-inside-control", "该车道中心线整体落在斑马线或停止线内,已按未裁剪几何发布。", publishedCoordinates[0])); }
// Lane markings are laid out along this line. Feeding it the clipped
// geometry is what keeps separators and arrows from being painted *past*
// a crossing: control avoidance only stops them landing *on* one.
markingLanes.push({ ...lane, coordinates: visibleCoordinates || publishedCoordinates });
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, cluster_id: boundaryCluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), cluster_boundary_clipped: Boolean(boundaryCluster && !internalCluster), control_clipped: laneWasClipped(publishedCoordinates, visibleCoordinates), source: "native-road-lane-centerline/v3-control-clipped" }, geometry: { type: "LineString", coordinates: visibleCoordinates || publishedCoordinates } });
}
byRoadId.set(road.id, lanes);
markingByRoadId.set(road.id, markingLanes);
}
for (const cluster of clusters) {
const clusterNodes = new Set(cluster.nodeIds.map(String));
const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
const compositeCenter = clusterCoordinates.length
? clusterCoordinates.reduce((sum, point) => [sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length], [0, 0])
: null;
const corridors = [];
for (const [nodeId, plan] of junctionPlans) {
if (!clusterNodes.has(String(nodeId))) continue;
for (const approach of plan.approaches) {
const end = approach.line.at(-1);
if ([...clusterNodes].some((candidate) => candidate !== String(nodeId) && distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3)) continue;
const heading = ((headingAtEndpoint(approach.line) + 180) % 360) - 180;
corridors.push({ nodeId, heading, approach, plan });
}
}
for (const corridor of corridors) {
const approach = corridor.approach; const plan = corridor.plan;
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const length = Math.min(distanceAlongLineToRadius(approach.line, compositeCenter, outerRadius), lineLengthMeters(approach.line));
if (length < 12) continue;
const line = approach.line; const outer = pointAlongLine(line, Math.max(0, length)); const inner = pointAlongLine(line, Math.min(Math.max(3, Number(cluster.coreRadiusMeters || 28) * .14), Math.max(3, length - 8)));
const corridorRoads = approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).filter(Boolean);
const incoming = corridorRoads.some((road) => String(road.sourceNodeIds.at(-1)) === String(corridor.nodeId));
const count = Math.max(1, corridorRoads.reduce((sum, road) => sum + road.laneCount, 0));
const laneWidth = approach.widthMeters / count;
const axis = project(inner, outer); const total = Math.hypot(...axis); if (!total) continue;
const normalized = [axis[0] / total, axis[1] / total]; const across = [-normalized[1], normalized[0]];
for (let index = 0; index < count; index += 1) {
const offset = approach.widthMeters / 2 - laneWidth * (index + .5);
const start = unproject([across[0] * offset, across[1] * offset], outer);
const end = unproject([across[0] * offset, across[1] * offset], inner);
const maneuver = incoming ? index === 0 ? "left" : index === count - 1 ? "right" : "through" : "outbound";
// Preview lanes are laid out radially from the outer radius inwards, so
// an untrimmed one runs straight over the arm crossing. Stop it at the
// first control: incoming lanes land on the stop bar, outgoing lanes on
// the far edge of the crossing.
const visible = trimLaneOutsideControls([start, end], controlFeatures, LANE_CENTERLINE_PROBE_WIDTH_METERS, compositeCenter);
features.push({ type: "Feature", properties: { native_id: `cluster-approach-lane:${cluster.id}:${approach.segmentId}:${index + 1}`, road_id: corridorRoads[0]?.id || null, cluster_id: cluster.id, cluster_preview: true, incoming, lane_index: index + 1, maneuver, control_clipped: laneWasClipped([start, end], visible), source: "native-road-junction-cluster-lane/v4-control-clipped" }, geometry: { type: "LineString", coordinates: visible || [start, end] } });
}
}
}
return { features, byRoadId, markingByRoadId };
}
function cubicTurnCurve(start, end, startHeading, endHeading, radius, turn, center) {
const reach = turn === "right" ? Math.max(5, radius * .75) : Math.max(9, radius * 1.35);
const first = offsetCoordinate(start, startHeading, reach);
const second = offsetCoordinate(end, endHeading, reach);
const points = [];
for (let index = 0; index <= 18; index += 1) {
const t = index / 18; const inverse = 1 - t;
points.push([
inverse ** 3 * start[0] + 3 * inverse ** 2 * t * first[0] + 3 * inverse * t ** 2 * second[0] + t ** 3 * end[0],
inverse ** 3 * start[1] + 3 * inverse ** 2 * t * first[1] + 3 * inverse * t ** 2 * second[1] + t ** 3 * end[1],
]);
}
return points.every((point) => point.every(Number.isFinite)) ? points : [start, center, end];
}
function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) {
const features = [];
const movements = [];
for (const connection of model.connections.filter((item) => item.enabled)) {
const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
const fromLanes = lanes.byRoadId.get(fromRoad?.id) || [];
const toLanes = lanes.byRoadId.get(endpointRoadId(model, connection.toEndpointId)) || [];
if (!fromLanes.length || !toLanes.length) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-missing-lane", "转向连接缺少可用车道中心线。", endpointCoordinate(model, connection.fromEndpointId))); continue; }
for (let index = 0; index < fromLanes.length; index += 1) {
const turn = connectionTurn(fromRoad, toRoad);
const defaultTargetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length);
const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex];
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue;
const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0];
const plan = junctionPlans.get(connection.nodeId);
// Cross intersections retain the earlier center-node curve while T junctions
// use lane tangents so their through movement does not bow toward the stem.
const coordinates = plan?.segmentIds.size === 4
? quadraticCurve(from, endpointCoordinate(model, connection.fromEndpointId), to, 12)
: connectorCurve(defaultFromLane.coordinates, defaultToLane.coordinates, turn);
const length = lineLengthMeters(coordinates);
const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
const provenance = override ? `override:${override.id}` : connection.provenance;
const connectorId = `connector:${id}`;
const geometryStatus = length < .4 ? "continuous" : length > 80 ? "deferred-too-long" : "connector";
const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus };
if (length < .4) { movements.push(movement); continue; }
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; }
const cluster = plan?.clusterId || null;
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, cluster_id: cluster, cluster_internal: Boolean(cluster), from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } });
movements.push(movement);
}
}
return { features, movements };
}
function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); }
function endpointRoadId(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId; }
function endpointCoordinate(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate; }
function connectionTurn(fromRoad, toRoad) {
if (!fromRoad || !toRoad) return "unknown";
const incoming = headingDegrees(fromRoad.centerline.at(-2), fromRoad.centerline.at(-1));
const outgoing = headingDegrees(toRoad.centerline[0], toRoad.centerline[1]);
const delta = ((outgoing - incoming + 540) % 360) - 180;
if (Math.abs(delta) >= 150) return "uturn";
if (Math.abs(delta) <= 30) return "through";
return delta > 0 ? "right" : "left";
}
function laneAllowsTurn(road, zeroIndex, turn) {
if (!road) return true;
const tag = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
if (!tag) return true;
const lanes = String(tag).split("|").map((lane) => lane.split(";").map((value) => value.trim().replace("slight_", "")).filter(Boolean));
const allowed = lanes[zeroIndex];
return !allowed || allowed.includes(turn) || turn === "uturn" && allowed.includes("reverse");
}
function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
if (turn === "left") return 0;
if (turn === "right") return targetCount - 1;
if (turn === "uturn") return 0;
return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1)));
}
function connectorCurve(incoming, outgoing, turn) {
const start = incoming.at(-1);
const end = outgoing[0];
if (turn === "through") return lineCurve(start, end, 12);
const incomingHeading = headingDegrees(incoming.at(-2), start);
const outgoingHeading = headingDegrees(end, outgoing[1]);
const chord = distanceMeters(start, end);
const incomingSpan = distanceMeters(incoming.at(-2), start);
const outgoingSpan = distanceMeters(end, outgoing[1]);
const tangentIntersection = intersectTangentRays(start, end, incomingHeading, outgoingHeading);
const fallbackDistance = Math.min(8, Math.max(.75, Math.min(chord * .42, incomingSpan * .8, outgoingSpan * .8)));
const firstDistance = tangentIntersection && tangentIntersection.incoming >= 0 ? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(.75, incomingSpan * 2.4))) / 3 : fallbackDistance;
const secondDistance = tangentIntersection && tangentIntersection.outgoing >= 0 ? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(.75, outgoingSpan * 2.4))) / 3 : fallbackDistance;
const firstControl = offsetCoordinate(start, incomingHeading, firstDistance);
const secondControl = offsetCoordinate(end, outgoingHeading + 180, secondDistance);
return cubicBezier(start, firstControl, secondControl, end, 12);
}
function intersectTangentRays(start, end, incomingHeading, outgoingHeading) {
const incoming = headingVector(incomingHeading);
const outgoing = headingVector(outgoingHeading);
const delta = project(end, start);
const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
if (Math.abs(cross) < 1e-6) return null;
return {
incoming: (delta[0] * outgoing[1] - delta[1] * outgoing[0]) / cross,
outgoing: (delta[0] * incoming[1] - delta[1] * incoming[0]) / cross,
};
}
function lineCurve(start, end, segments) {
return Array.from({ length: segments + 1 }, (_, index) => interpolate(start, end, index / segments));
}
function cubicBezier(a, firstControl, secondControl, b, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments; const u = 1 - t;
result.push([u ** 3 * a[0] + 3 * u * u * t * firstControl[0] + 3 * u * t * t * secondControl[0] + t ** 3 * b[0], u ** 3 * a[1] + 3 * u * u * t * firstControl[1] + 3 * u * t * t * secondControl[1] + t ** 3 * b[1]]);
}
return result;
}
function quadraticCurve(a, control, b, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments; const u = 1 - t;
result.push([u * u * a[0] + 2 * u * t * control[0] + t * t * b[0], u * u * a[1] + 2 * u * t * control[1] + t * t * b[1]]);
}
return result;
}
function offsetLine(line, offsetMeters) {
if (line.length < 2) return null;
const origin = line[0]; const points = line.map((point) => project(point, origin)); const result = [];
for (let index = 0; index < points.length; index += 1) {
const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)];
const dx = next[0] - previous[0]; const dy = next[1] - previous[1]; const length = Math.hypot(dx, dy);
if (length < 0.01) return null;
result.push(unproject([points[index][0] - dy / length * offsetMeters, points[index][1] + dx / length * offsetMeters], origin));
}
return result;
}
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
function polygonAreaMeters(ring) {
if (ring.length < 3) return 0;
const origin = ring[0];
const points = ring.map((point) => project(point, origin));
let twiceArea = 0;
for (let index = 0; index < points.length; index += 1) {
const next = points[(index + 1) % points.length];
twiceArea += points[index][0] * next[1] - next[0] * points[index][1];
}
return Math.abs(twiceArea) / 2;
}
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics, options = {}) {
const result = [];
const complexClusters = new Set((options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []).filter((cluster) => cluster.template === "complex-junction-v1").map((cluster) => cluster.id));
for (const [nodeId, plan] of junctionPlans) {
if (plan.clusterId && complexClusters.has(plan.clusterId)) continue;
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
if (boundary.length < 3 || !junctionMovements.length) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
continue;
}
const approachAreaMeters = polygonAreaMeters(boundary);
let ring = [...boundary, boundary[0]];
let boundaryMode = plan.boundaryMode || "approach-envelope";
if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS)))) {
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
ring = [...envelope, envelope[0]];
boundaryMode = "connector-convex-fallback";
}
if (hasSelfIntersection(ring)) {
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
continue;
}
const surfaceAreaMeters = polygonAreaMeters(ring);
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, template: plan.template || null, template_reference: plan.templateReference || null, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: plan.template ? "junction-cross-template/v1" : "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node));
if (plan.boundaryFallbacks) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-rounded-corner-fallback", "部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。", node));
if (!plan.clusterId) diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
for (const cluster of options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []) {
if (cluster.template === "complex-junction-v1") continue;
const clusterNodes = new Set(cluster.nodeIds.map(String));
const members = result.filter((feature) => clusterNodes.has(String(feature.properties.osm_node_id)));
if (members.length < 2) continue;
const points = [];
const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
const clusterCenter = clusterCoordinates.reduce((sum, point) => [sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length], [0, 0]);
for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(clusterCenter, index * 45, 12));
for (const [nodeId, plan] of junctionPlans) {
if (!clusterNodes.has(String(nodeId))) continue;
for (const approach of plan.approaches) {
const end = approach.line.at(-1);
if ([...clusterNodes].some((candidate) => candidate !== String(nodeId) && distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3)) continue;
const cutback = pointAlongLine(approach.line, Math.min(plan.cutbackMeters, Math.max(12, cluster.approachLengthMeters * .5)));
const heading = headingAtEndpoint(approach.line); const half = approach.widthMeters / 2;
points.push(offsetCoordinate(cutback, heading + 90, half), offsetCoordinate(cutback, heading - 90, half));
}
const node = plan.node;
for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(node, index * 45, 9));
}
const hull = convexHull(points);
if (hull.length < 3) continue;
const ring = roundedHull(hull, 0.22);
const memberIds = new Set(members.map((feature) => feature.properties.native_id));
for (let index = result.length - 1; index >= 0; index -= 1) if (memberIds.has(result[index].properties.native_id)) result.splice(index, 1);
result.push({ type: "Feature", properties: { native_id: `junction-cluster:${cluster.id}`, osm_node_ids: [...clusterNodes].join(","), kind: "cluster", template: cluster.template, boundary_mode: "cluster-import-core", center: clusterCenter, member_count: members.length, movement_count: movements.filter((movement) => clusterNodes.has(String(movement.nodeId))).length, connector_count: connectors.filter((feature) => clusterNodes.has(String(feature.properties.node_id))).length, surface_area_m2: Math.round(polygonAreaMeters(ring) * 10) / 10, rule: "junction-cluster-template/v2" }, geometry: { type: "Polygon", coordinates: [[...ring, ring[0]]] } });
diagnostics.push(diagnostic("info", `junction-cluster:${cluster.id}`, [...clusterNodes], "junction-cluster-core-applied", "已按外部进口截面和簇节点核心生成受限复合路口面。", ring[0]));
}
return result;
}
function activeComplexCluster(options, clusterId) {
return Boolean(clusterId && (options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []).some((cluster) => cluster.id === clusterId && cluster.template === "complex-junction-v1"));
}
function compileJunctionPlans(model, options = {}, diagnostics = []) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
byNode.get(endpoint.nodeId).push(endpoint);
}
const plans = new Map();
const clusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
for (const [nodeId, endpoints] of byNode) {
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, "")));
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
const approaches = junctionApproaches(model, endpoints);
if (approaches.length !== segmentIds.size) continue;
// Rounded curb corners need enough approach length to retain the full
// turning envelope after the corner is cut toward the junction.
const baseCutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const node = endpoints[0].coordinate;
const template = junctionTemplateFor(nodeId, segmentIds, options.junctionTemplates, diagnostics, node);
const cutbackMeters = baseCutbackMeters * (template?.cutbackMultiplier || 1);
const boundary = junctionBoundary(approaches, node, cutbackMeters, template?.cornerRadiusMultiplier || 1, template?.approachWidthMultiplier || 1);
if (boundary.points.length < 3) continue;
const cluster = clusterByNode.get(String(nodeId));
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks, template: template?.template || null, templateReference: template?.referenceFile || null, approachWidthMultiplier: template?.approachWidthMultiplier || 1, approachLengthMeters: template?.approachLengthMeters || 0, clusterId: cluster?.id || null });
}
return plans;
}
function junctionApproaches(model, endpoints) {
const groups = new Map();
for (const endpoint of endpoints) {
const road = model.roads.find((item) => item.id === endpoint.roadId);
if (!road) continue;
const key = road.segmentId;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push({ endpoint, road });
}
return [...groups.values()].map((directions) => {
const { endpoint, road } = directions[0];
return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) };
});
}
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 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));
if (ordered.length < 3) return { points: [], mode: "approach-envelope" };
const boundary = [];
let rounded = 0;
let fallbacks = 0;
for (let index = 0; index < ordered.length; index += 1) {
const first = ordered[index]; const second = ordered[(index + 1) % ordered.length];
boundary.push(first.point);
// One physical OSM way is often split at an intersection node. Its two
// opposite approaches share a continuous road edge; rounding that edge
// bends the far side of a T junction and exposes junction asphalt beyond
// the pedestrian strip.
if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue;
const curve = roundedCorner(node, first.point, second.point, first.outwardHeading, second.outwardHeading, cornerRadiusMultiplier);
if (!curve) { fallbacks += 1; continue; }
boundary.push(...curve.slice(1, -1));
rounded += 1;
}
return { points: boundary, mode: rounded ? "rounded-approach-envelope" : "approach-envelope", fallbacks };
}
function templateApproachRing(approach, plan) {
const innerDistance = plan.cutbackMeters;
const availableLength = lineLengthMeters(approach.line) - innerDistance - .5;
const lengthMeters = Math.min(plan.approachLengthMeters, availableLength);
if (lengthMeters < 10) return null;
const outerDistance = innerDistance + lengthMeters;
const inner = pointAlongLine(approach.line, innerDistance);
const outer = pointAlongLine(approach.line, outerDistance);
const heading = headingAtEndpoint(approach.line);
const innerHalf = approach.widthMeters * plan.approachWidthMultiplier / 2;
const outerHalf = approach.widthMeters / 2;
const ring = [
offsetCoordinate(outer, heading + 90, outerHalf),
offsetCoordinate(inner, heading + 90, innerHalf),
offsetCoordinate(inner, heading - 90, innerHalf),
offsetCoordinate(outer, heading - 90, outerHalf),
offsetCoordinate(outer, heading + 90, outerHalf),
];
return ring.every((point) => point.every(Number.isFinite)) ? { ring, lengthMeters } : null;
}
function roundedHull(hull, factor) {
const result = [];
for (let index = 0; index < hull.length; index += 1) {
const previous = hull[(index - 1 + hull.length) % hull.length];
const current = hull[index];
const next = hull[(index + 1) % hull.length];
const entry = interpolate(previous, current, factor);
const exit = interpolate(current, next, factor);
result.push(entry);
const curve = quadraticCurve(entry, current, exit, 4);
result.push(...curve.slice(1, -1));
result.push(exit);
}
return result;
}
function isStraightJunctionEdge(first, second) {
if (first.sourceWayKey !== second.sourceWayKey) return false;
const radians = (first.outwardHeading - second.outwardHeading) * Math.PI / 180;
return Math.cos(radians) <= -0.98;
}
function roundedCorner(node, first, second, firstHeading, secondHeading, radiusMultiplier = 1) {
const origin = node;
const a = project(first, origin); const b = project(second, origin);
const chord = Math.hypot(a[0] - b[0], a[1] - b[1]);
if (chord < .5 || !Number.isFinite(firstHeading) || !Number.isFinite(secondHeading)) return null;
const firstDirection = headingVector(firstHeading);
const secondDirection = headingVector(secondHeading);
const intersection = lineIntersection(a, firstDirection, b, secondDirection);
if (!intersection) return null;
const controlDistance = Math.hypot(...intersection);
const endpointDistance = Math.max(Math.hypot(...a), Math.hypot(...b));
// Adjacent approach edge tangents should meet in the corner between the
// node and the cutback. Reject near-parallel or remote intersections rather
// than publishing a huge/self-crossing curve.
if (controlDistance < .01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null;
const scaledIntersection = [intersection[0] * radiusMultiplier, intersection[1] * radiusMultiplier];
const control = unproject(scaledIntersection, origin);
return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS);
}
function junctionTemplateFor(nodeId, segmentIds, configured, diagnostics, node) {
if (!configured?.enabled) return null;
const entry = (configured.references || []).find((item) => String(item.nodeId) === String(nodeId));
if (!entry) return null;
if (segmentIds.size !== 4) {
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "junction-template-topology-skip", "cross 模板只应用于四臂路口,当前路口保留 native 几何。", node));
return null;
}
if (entry.template !== "cross-v1") {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-unsupported", "路口模板名称不受支持,已回退 native 几何。", node));
return null;
}
const multiplier = Number(entry.cornerRadiusMultiplier ?? 1);
if (!Number.isFinite(multiplier) || multiplier < 0.75 || multiplier > 1.25) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板圆角参数必须在 0.75 到 1.25 之间,已回退 native 几何。", node));
return null;
}
const cutbackMultiplier = Number(entry.cutbackMultiplier ?? 1);
if (!Number.isFinite(cutbackMultiplier) || cutbackMultiplier < 1 || cutbackMultiplier > 1.35) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板进口过渡参数必须在 1 到 1.35 之间,已回退 native 几何。", node));
return null;
}
const approachWidthMultiplier = Number(entry.approachWidthMultiplier ?? 1);
if (!Number.isFinite(approachWidthMultiplier) || approachWidthMultiplier < 1 || approachWidthMultiplier > 1.8) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板进口宽度参数必须在 1 到 1.8 之间,已回退 native 几何。", node));
return null;
}
const approachLengthMeters = Number(entry.approachLengthMeters ?? 24);
if (!Number.isFinite(approachLengthMeters) || approachLengthMeters < 10 || approachLengthMeters > 50) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-invalid-parameter", "cross 模板进口过渡长度必须在 10 到 50 米之间,已回退 native 几何。", node));
return null;
}
if (entry.referenceFile && !fs.existsSync(entry.referenceFile)) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-template-reference-missing", "路口参考文件不存在,已回退 native 几何。", node));
return null;
}
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "junction-template-applied", "已按 cross-v1 模板规整路口面;道路、车道、连接器和控制设施保持 native 结果。", node));
return { template: entry.template, referenceFile: entry.referenceFile || null, cornerRadiusMultiplier: multiplier, cutbackMultiplier, approachWidthMultiplier, approachLengthMeters };
}
function headingVector(degrees) {
const radians = degrees * Math.PI / 180;
return [Math.sin(radians), Math.cos(radians)];
}
function lineIntersection(firstPoint, firstDirection, secondPoint, secondDirection) {
const cross = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
if (Math.abs(cross) < 1e-4) return null;
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
const firstDistance = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / cross;
return [firstPoint[0] + firstDirection[0] * firstDistance, firstPoint[1] + firstDirection[1] * firstDistance];
}
function pointAlongLine(line, meters) {
let remaining = meters;
for (let index = 1; index < line.length; index += 1) {
const length = distanceMeters(line[index - 1], line[index]);
if (length >= remaining) return interpolate(line[index - 1], line[index], remaining / length);
remaining -= length;
}
return line.at(-1);
}
function pointAndAxisAlongLine(line, meters) {
let remaining = meters;
for (let index = 1; index < line.length; index += 1) {
const start = line[index - 1]; const end = line[index];
const length = distanceMeters(start, end);
if (length < 0.01) continue;
if (length >= remaining) {
const vector = project(end, start);
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
}
remaining -= length;
}
return null;
}
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
if (!startCutback && !endCutback) return line;
const total = lineLengthMeters(line);
// Short OSM fragments cannot safely lose both ends. Keep their source
// geometry intact and let the junction diagnostic surface the ambiguity.
if (startCutback + endCutback >= total - 0.5) return line;
const result = [];
let traversed = 0;
const start = pointAlongLine(line, startCutback);
const end = pointAlongLine(line, total - endCutback);
result.push(start);
for (let index = 1; index < line.length - 1; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
}
result.push(end);
return result;
}
function trimLineAtComplexCluster(line, sourceNodeIds, junctionPlans, cluster, center) {
if (!center || line.length < 2) return trimLineAtJunctions(line, sourceNodeIds, junctionPlans);
const boundaryRadius = complexJunctionMetrics(cluster).approachOuterRadius;
const startInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds[0]));
const endInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds.at(-1)));
const available = lineLengthMeters(line);
const startDistance = startInCluster ? distanceAlongLineToRadius(line, center, boundaryRadius) : 0;
const endDistance = endInCluster ? distanceAlongLineToRadius([...line].reverse(), center, boundaryRadius) : 0;
const startCutback = startDistance > 0 && available > startDistance + 1 ? startDistance : 0;
const endCutback = endDistance > 0 && available > endDistance + 1 ? endDistance : 0;
if (!startCutback && !endCutback) return line;
return trimLineRange(line, startCutback, endCutback);
}
function distanceAlongLineToRadius(line, center, radius) {
if (!center || line.length < 2) return 0;
const heading = headingAtEndpoint(line);
const vector = project(line[0], center);
const radians = heading * Math.PI / 180;
const startRadius = vector[0] * Math.sin(radians) + vector[1] * Math.cos(radians);
return Math.max(0, radius - startRadius);
}
function trimLineRange(line, startCutback, endCutback) {
const total = lineLengthMeters(line);
if (startCutback + endCutback >= total - .5) return line;
const result = [pointAlongLine(line, startCutback)];
let traversed = 0;
for (let index = 1; index < line.length - 1; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
}
result.push(pointAlongLine(line, total - endCutback));
return result;
}
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); }
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
function convexHull(points) {
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (unique.length < 3) return unique;
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const lower = []; for (const point of unique) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop(); lower.push(point); }
const upper = []; for (const point of [...unique].reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop(); upper.push(point); }
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}
function interpolate(a, b, ratio) { return [a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio]; }
function distanceMeters(a, b) { const dx = (b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180); const dy = (b[1] - a[1]) * 111320; return Math.hypot(dx, dy); }
function hasSelfIntersection(ring) {
for (let first = 0; first < ring.length - 1; first += 1) for (let second = first + 1; second < ring.length - 1; second += 1) {
if (Math.abs(first - second) <= 1 || first === 0 && second === ring.length - 2) continue;
if (segmentsIntersect(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
}
return false;
}
function segmentsIntersect(a, b, c, d) {
const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
const abC = cross(a, b, c); const abD = cross(a, b, d); const cdA = cross(c, d, a); const cdB = cross(c, d, b);
return (abC > 0 && abD < 0 || abC < 0 && abD > 0) && (cdA > 0 && cdB < 0 || cdA < 0 && cdB > 0);
}
function circleRing(center, radius, segments) {
const origin = center;
const ring = [];
for (let index = 0; index <= segments; index += 1) {
const angle = index / segments * Math.PI * 2;
ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin));
}
return ring;
}
function roadRing(line, width) {
if (line.length < 2 || !Number.isFinite(width)) return null;
const origin = line[0];
const points = line.map((point) => project(point, origin));
const left = []; const right = [];
const half = width / 2;
for (let i = 0; i < points.length; i += 1) {
const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)];
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));
}
const ring = [...left, ...right.reverse(), left[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}
function sidewalkRing(line, innerOffset, outerOffset, side) {
const inner = offsetLine(line, innerOffset * side);
const outer = offsetLine(line, outerOffset * side);
if (!inner || !outer) return null;
const ring = [...inner, ...outer.reverse(), inner[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
}
function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; }
function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; }
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; }
function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; }
function parseTags(body) { const tags = {}; for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; }
function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; }
function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; }
function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); }
module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };