667 lines
28 KiB
JavaScript
667 lines
28 KiB
JavaScript
"use strict";
|
||
|
||
const fs = require("fs");
|
||
const { parseOsm } = require("./osm");
|
||
const {
|
||
appendCoordinates,
|
||
haversineMeters,
|
||
laneCenterline,
|
||
lateralOffsetFrom,
|
||
orientPolyline,
|
||
polylineLength,
|
||
polylineMidpoint,
|
||
} = require("./lane-geometry");
|
||
|
||
const MAX_ROUTES = 5;
|
||
const MAX_PATH_EDGES = 7;
|
||
const MIN_ROUTE_EDGES = 3;
|
||
const MAX_LANE_DISTANCE_METERS = 20;
|
||
const MIN_LATERAL_SEPARATION_METERS = 0.25;
|
||
const JUNCTION_TRIM_METERS = 6.0;
|
||
const CONNECTOR_SURFACE_TOLERANCE_METERS = 0.35;
|
||
const ALL_TURNS = new Set(["left", "through", "right"]);
|
||
|
||
function buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath) {
|
||
if (!lanePolygonsPath || !networkPath || !intersectionSurfacePath) {
|
||
throw new Error("Lane polygons, osm2streets network, and intersection surface paths are required for vehicle route generation");
|
||
}
|
||
const osm = parseOsm(fs.readFileSync(osmPath, "utf8"));
|
||
const lanePolygons = readLanePolygons(lanePolygonsPath);
|
||
const network = readJsonObject(networkPath, "osm2streets network");
|
||
const intersectionSurfaces = readFeatureCollection(intersectionSurfacePath, "intersection surfaces");
|
||
const diagnostics = [];
|
||
const laneIndex = indexDrivingLanes(lanePolygons.features, diagnostics);
|
||
const intersections = indexIntersections(network, intersectionSurfaces.features);
|
||
const edges = directedRoadEdges(network, osm.ways, diagnostics);
|
||
const candidates = findReturnRoutes(edges);
|
||
const routes = [];
|
||
for (const candidate of candidates) {
|
||
const route = makeRoute(candidate, laneIndex, intersections, diagnostics);
|
||
if (route) routes.push(route);
|
||
}
|
||
const selected = selectRoutes(routes);
|
||
return {
|
||
source: osmPath,
|
||
laneSource: lanePolygonsPath,
|
||
networkSource: networkPath,
|
||
intersectionSource: intersectionSurfacePath,
|
||
bounds: osm.bounds,
|
||
generatedAt: new Date().toISOString(),
|
||
speedMetersPerSecond: 8.0,
|
||
loop: true,
|
||
routes: selected,
|
||
diagnostics,
|
||
// 旧预览仍读取 segments;保持与 routes 为同一个数组引用。
|
||
segments: selected,
|
||
};
|
||
}
|
||
|
||
function readLanePolygons(file) {
|
||
return readFeatureCollection(file, "lane polygons");
|
||
}
|
||
|
||
function readFeatureCollection(file, label) {
|
||
const collection = readJsonObject(file, label);
|
||
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
|
||
throw new Error(`Invalid ${label} GeoJSON '${file}': expected FeatureCollection`);
|
||
}
|
||
return collection;
|
||
}
|
||
|
||
function readJsonObject(file, label) {
|
||
try {
|
||
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected JSON object");
|
||
return value;
|
||
} catch (error) {
|
||
throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`);
|
||
}
|
||
}
|
||
|
||
function indexDrivingLanes(features, diagnostics) {
|
||
const index = new Map();
|
||
features.forEach((feature, featureIndex) => {
|
||
if (feature?.properties?.type !== "Driving") return;
|
||
const centerline = laneCenterline(feature);
|
||
const direction = feature.properties.direction;
|
||
const widthMeters = Number(feature.properties.width);
|
||
const road = Number(feature.properties.road);
|
||
if (!centerline || !["Fwd", "Back"].includes(direction) || !Number.isFinite(widthMeters) || widthMeters <= 0 || !Number.isInteger(road)) {
|
||
diagnostics.push({
|
||
reason: "invalid_lane_polygon",
|
||
featureIndex,
|
||
road: feature?.properties?.road ?? null,
|
||
laneIndex: feature?.properties?.index ?? null,
|
||
});
|
||
return;
|
||
}
|
||
const lane = {
|
||
featureIndex,
|
||
polygonId: feature.id ?? `${feature.properties.road ?? "road"}:${direction}:${feature.properties.index ?? featureIndex}`,
|
||
road,
|
||
laneIndex: feature.properties.index,
|
||
widthMeters,
|
||
allowedTurns: normalizeAllowedTurns(feature.properties.allowed_turns),
|
||
centerline,
|
||
};
|
||
const key = laneKey(road, direction);
|
||
if (!index.has(key)) index.set(key, []);
|
||
index.get(key).push(lane);
|
||
});
|
||
return index;
|
||
}
|
||
|
||
function normalizeAllowedTurns(value) {
|
||
if (!Array.isArray(value)) return new Set();
|
||
return new Set(value.map(normalizeTurn).filter(Boolean));
|
||
}
|
||
|
||
function isCruiseHighway(tags) {
|
||
const highway = tags.highway || "";
|
||
if (!highway || tags.area === "yes") return false;
|
||
return !new Set([
|
||
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
|
||
"bridleway", "corridor", "elevator", "platform", "construction",
|
||
]).has(highway);
|
||
}
|
||
|
||
function directedRoadEdges(network, ways, diagnostics) {
|
||
if (!Array.isArray(network.roads) || !network.gps_bounds) {
|
||
throw new Error("Invalid osm2streets network: expected roads and gps_bounds");
|
||
}
|
||
const waysById = new Map(ways.map((way) => [String(way.id), way]));
|
||
const edges = [];
|
||
for (const entry of network.roads) {
|
||
const road = Array.isArray(entry) ? entry[1] : null;
|
||
if (!road || !Number.isInteger(Number(road.id)) || !Array.isArray(road.lane_specs_ltr)) continue;
|
||
const wayIds = Array.isArray(road.osm_ids) ? road.osm_ids.map(String) : [];
|
||
const sourceWays = wayIds.map((id) => waysById.get(id)).filter(Boolean);
|
||
const sourceWay = sourceWays[0] || null;
|
||
const tags = sourceWay?.tags || { highway: road.highway_type || "" };
|
||
if (!isCruiseHighway(tags)) continue;
|
||
if (sourceWays.length > 1 && sourceWays.some((way) => JSON.stringify(way.tags) !== JSON.stringify(sourceWay.tags))) {
|
||
addDiagnostic(diagnostics, { reason: "ambiguous_internal_road_source", road: road.id, osmWayIds: wayIds });
|
||
continue;
|
||
}
|
||
const coordinates = networkPolylineToGps(road.center_line, network.gps_bounds);
|
||
if (coordinates.length < 2 || routeLength(coordinates) < 12) continue;
|
||
const directions = new Set(road.lane_specs_ltr
|
||
.filter((lane) => lane.lt === "Driving")
|
||
.map((lane) => lane.dir));
|
||
if (directions.has("Fwd")) edges.push(makeEdge(road, sourceWay, wayIds, coordinates, "forward"));
|
||
if (directions.has("Back")) edges.push(makeEdge(road, sourceWay, wayIds, [...coordinates].reverse(), "backward"));
|
||
}
|
||
return edges.sort((a, b) => a.id.localeCompare(b.id));
|
||
}
|
||
|
||
function makeEdge(road, way, wayIds, coordinates, direction) {
|
||
const forward = direction === "forward";
|
||
const tags = way?.tags || {};
|
||
return {
|
||
id: `road-${road.id}:${direction}`,
|
||
roadId: Number(road.id),
|
||
wayId: wayIds[0] || "",
|
||
osmWayIds: wayIds,
|
||
direction,
|
||
name: road.name || tags.name || road.highway_type || "road",
|
||
highway: road.highway_type || tags.highway || "",
|
||
oneWay: directionsForRoad(road).size === 1 ? "yes" : "",
|
||
startNode: forward ? Number(road.src_i) : Number(road.dst_i),
|
||
endNode: forward ? Number(road.dst_i) : Number(road.src_i),
|
||
coordinates,
|
||
allowedTurns: allowedTurns(tags, direction),
|
||
turnLanes: turnLanes(tags, direction),
|
||
};
|
||
}
|
||
|
||
function directionsForRoad(road) {
|
||
return new Set(road.lane_specs_ltr.filter((lane) => lane.lt === "Driving").map((lane) => lane.dir));
|
||
}
|
||
|
||
function networkPolylineToGps(polyline, bounds) {
|
||
const points = Array.isArray(polyline?.pts) ? polyline.pts : [];
|
||
const widthMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.max_lon, bounds.min_lat]);
|
||
const heightMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.min_lon, bounds.max_lat]);
|
||
if (!(widthMeters > 0) || !(heightMeters > 0)) return [];
|
||
return points.map((point) => {
|
||
const x = Number(point.x) / 10000;
|
||
const y = Number(point.y) / 10000;
|
||
return [
|
||
bounds.min_lon + x / widthMeters * (bounds.max_lon - bounds.min_lon),
|
||
bounds.min_lat + (bounds.max_lat - bounds.min_lat) * (heightMeters - y) / heightMeters,
|
||
];
|
||
}).filter((coordinate) => coordinate.every(Number.isFinite));
|
||
}
|
||
|
||
function isOneWay(value) {
|
||
return ["yes", "true", "1"].includes(value);
|
||
}
|
||
|
||
function allowedTurns(tags, direction) {
|
||
const lanes = turnLanes(tags, direction);
|
||
if (!lanes) return ALL_TURNS;
|
||
const turns = new Set(lanes.flatMap((lane) => [...lane]).filter((turn) => ALL_TURNS.has(turn)));
|
||
return turns.size ? turns : ALL_TURNS;
|
||
}
|
||
|
||
function turnLanes(tags, direction) {
|
||
const value = tags[`turn:lanes:${direction}`] ?? tags["turn:lanes"];
|
||
if (value === undefined || value === "") return null;
|
||
return String(value).split("|").map((lane) => {
|
||
const turns = new Set(String(lane).split(";").map(normalizeTurn).filter(Boolean));
|
||
return turns.size ? turns : new Set(ALL_TURNS);
|
||
});
|
||
}
|
||
|
||
function normalizeTurn(value) {
|
||
const turn = String(value || "").trim().replace(/^slight_/, "");
|
||
if (turn === "reverse") return "u_turn";
|
||
return [...ALL_TURNS, "u_turn"].includes(turn) ? turn : null;
|
||
}
|
||
|
||
function findReturnRoutes(edges) {
|
||
const outgoing = new Map();
|
||
const byId = new Map();
|
||
for (const edge of edges) {
|
||
if (!outgoing.has(edge.startNode)) outgoing.set(edge.startNode, []);
|
||
outgoing.get(edge.startNode).push(edge);
|
||
byId.set(edge.id, edge);
|
||
}
|
||
const candidates = [];
|
||
const seen = new Set();
|
||
for (const first of edges) walkToTerminal([first], [], outgoing, byId, candidates, seen);
|
||
return candidates.sort((a, b) => a.signature.localeCompare(b.signature));
|
||
}
|
||
|
||
function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
|
||
const current = path.at(-1);
|
||
if (path.length >= MIN_ROUTE_EDGES) {
|
||
const route = returnRoute(path, maneuvers, byId);
|
||
if (route && !seen.has(route.signature)) {
|
||
seen.add(route.signature);
|
||
candidates.push(route);
|
||
}
|
||
}
|
||
if (path.length >= MAX_PATH_EDGES) return;
|
||
const nextSteps = [];
|
||
for (const next of outgoing.get(current.endNode) || []) {
|
||
if (path.some((edge) => edge.id === next.id)) continue;
|
||
const maneuver = classifyConnection(current, next);
|
||
if (!maneuver || !current.allowedTurns.has(maneuver)) continue;
|
||
nextSteps.push({ edge: next, maneuver });
|
||
}
|
||
for (const step of nextSteps) {
|
||
walkToTerminal([...path, step.edge], [...maneuvers, step.maneuver], outgoing, byId, candidates, seen);
|
||
}
|
||
}
|
||
|
||
function classifyConnection(incoming, outgoing) {
|
||
if (incoming.roadId === outgoing.roadId) return null;
|
||
const inVector = directionVector(incoming.coordinates.at(-2), incoming.coordinates.at(-1));
|
||
const outVector = directionVector(outgoing.coordinates[0], outgoing.coordinates[1]);
|
||
const dot = inVector.x * outVector.x + inVector.y * outVector.y;
|
||
const cross = inVector.x * outVector.y - inVector.y * outVector.x;
|
||
const angle = Math.atan2(cross, dot) * 180 / Math.PI;
|
||
if (Math.abs(angle) >= 150) return null;
|
||
if (Math.abs(angle) <= 35) return "through";
|
||
return angle > 0 ? "left" : "right";
|
||
}
|
||
|
||
function directionVector(a, b) {
|
||
const scale = 111320;
|
||
const x = (b[0] - a[0]) * scale * Math.cos(degreesToRadians((a[1] + b[1]) / 2));
|
||
const y = (b[1] - a[1]) * scale;
|
||
const length = Math.hypot(x, y) || 1;
|
||
return { x: x / length, y: y / length };
|
||
}
|
||
|
||
function returnRoute(path, forwardManeuvers, byId) {
|
||
const reverse = path.slice().reverse().map((edge) => byId.get(`road-${edge.roadId}:${oppositeDirection(edge.direction)}`));
|
||
if (reverse.some((edge) => !edge)) return null;
|
||
const returnManeuvers = [];
|
||
for (let index = 1; index < reverse.length; index += 1) {
|
||
const maneuver = classifyConnection(reverse[index - 1], reverse[index]);
|
||
if (!maneuver) return null;
|
||
returnManeuvers.push(maneuver);
|
||
}
|
||
const signature = path.map((edge) => edge.roadId).join(">");
|
||
return {
|
||
edges: [...path, ...reverse],
|
||
maneuvers: [...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
|
||
forwardEdgeCount: path.length,
|
||
signature,
|
||
};
|
||
}
|
||
|
||
function oppositeDirection(direction) {
|
||
return direction === "forward" ? "backward" : "forward";
|
||
}
|
||
|
||
function makeRoute(candidate, laneIndex, intersections, diagnostics) {
|
||
const selectedLanes = [];
|
||
for (let index = 0; index < candidate.edges.length; index += 1) {
|
||
const edge = candidate.edges[index];
|
||
const match = selectLaneForEdge(edge, candidate.maneuvers[index], laneIndex, {
|
||
// 仅去程中的真实路口受 turn:lanes 严格约束;端点调头与展示返程不能被反向标签否决。
|
||
enforceTurnRestrictions: index < candidate.forwardEdgeCount - 1,
|
||
});
|
||
if (!match.ok) {
|
||
addDiagnostic(diagnostics, {
|
||
reason: match.reason,
|
||
routeSignature: candidate.signature,
|
||
edgeId: edge.id,
|
||
road: edge.roadId,
|
||
osmWayId: edge.wayId,
|
||
direction: edge.direction,
|
||
maneuver: candidate.maneuvers[index],
|
||
detail: match.detail,
|
||
});
|
||
return null;
|
||
}
|
||
selectedLanes.push(match.lane);
|
||
}
|
||
const smoothed = smoothLaneRoute(candidate.edges, selectedLanes, intersections);
|
||
if (!smoothed.ok) {
|
||
addDiagnostic(diagnostics, { reason: smoothed.reason, routeSignature: candidate.signature, ...smoothed.detail });
|
||
return null;
|
||
}
|
||
const coordinates = smoothed.coordinates;
|
||
const centerlineCoordinates = smoothRoute(candidate.edges);
|
||
const route = {
|
||
id: `route-${candidate.signature.replace(/[^\w]+/g, "-")}`,
|
||
highway: candidate.edges[0].highway,
|
||
oneWay: candidate.edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
|
||
edgeIds: candidate.edges.map((edge) => edge.id),
|
||
maneuvers: candidate.maneuvers,
|
||
lengthMeters: routeLength(coordinates),
|
||
coordinates,
|
||
centerlineCoordinates,
|
||
laneSegments: selectedLanes.flatMap((lane, edgeIndex) => lane.fragments.map((fragment) => ({
|
||
edgeId: candidate.edges[edgeIndex].id,
|
||
osmWayId: candidate.edges[edgeIndex].wayId,
|
||
direction: candidate.edges[edgeIndex].direction,
|
||
laneIndex: lane.laneIndex,
|
||
widthMeters: fragment.widthMeters,
|
||
centerOffsetMeters: Number(fragment.centerOffsetMeters.toFixed(3)),
|
||
maneuver: candidate.maneuvers[edgeIndex],
|
||
source: "lane_polygon_centerline",
|
||
polygonId: fragment.polygonId,
|
||
featureIndex: fragment.featureIndex,
|
||
road: fragment.road,
|
||
}))),
|
||
connectors: smoothed.connectors,
|
||
};
|
||
Object.defineProperty(route, "signature", { value: candidate.signature });
|
||
return route;
|
||
}
|
||
|
||
function selectLaneForEdge(edge, maneuver, laneIndex, options = {}) {
|
||
const enforceTurnRestrictions = options.enforceTurnRestrictions !== false;
|
||
const expectedDirection = edge.direction === "forward" ? "Fwd" : "Back";
|
||
const candidates = laneIndex.get(laneKey(edge.roadId, expectedDirection)) || [];
|
||
if (!candidates.length) return { ok: false, reason: "missing_lane_polygon" };
|
||
const lanes = [];
|
||
for (const fragment of candidates) {
|
||
const centerline = orientPolyline(fragment.centerline, edge.coordinates);
|
||
if (!centerline) return { ok: false, reason: "invalid_lane_polygon", detail: "direction_alignment" };
|
||
const midpoint = polylineMidpoint(centerline);
|
||
const offset = lateralOffsetFrom(edge.coordinates, midpoint);
|
||
if (!offset || offset.distance > MAX_LANE_DISTANCE_METERS) {
|
||
return { ok: false, reason: "missing_lane_polygon", detail: "geometry_too_far_from_internal_road" };
|
||
}
|
||
lanes.push({
|
||
laneIndex: fragment.laneIndex,
|
||
centerline,
|
||
centerOffsetMeters: offset.lateral,
|
||
allowedTurns: fragment.allowedTurns,
|
||
fragments: [{ ...fragment, centerline, centerOffsetMeters: offset.lateral }],
|
||
});
|
||
}
|
||
lanes.sort((a, b) => a.centerOffsetMeters - b.centerOffsetMeters || String(a.laneIndex).localeCompare(String(b.laneIndex)));
|
||
for (let index = 1; index < lanes.length; index += 1) {
|
||
if (lanes[index].centerOffsetMeters - lanes[index - 1].centerOffsetMeters < MIN_LATERAL_SEPARATION_METERS) {
|
||
return { ok: false, reason: "ambiguous_lane_order" };
|
||
}
|
||
}
|
||
if (enforceTurnRestrictions && edge.turnLanes && edge.turnLanes.length !== lanes.length) {
|
||
return { ok: false, reason: "ambiguous_lane_order", detail: "turn_lane_count_mismatch" };
|
||
}
|
||
let compatible = lanes.filter((lane, index) => laneSupportsManeuver(lane, edge.turnLanes?.[index], maneuver));
|
||
if (!compatible.length && !enforceTurnRestrictions) compatible = lanes;
|
||
if (!compatible.length) return { ok: false, reason: "no_compatible_turn_lane" };
|
||
const chooseLeft = maneuver === "left" || maneuver === "u_turn";
|
||
return { ok: true, lane: chooseLeft ? compatible[0] : compatible.at(-1) };
|
||
}
|
||
|
||
function laneSupportsManeuver(lane, osmTurns, maneuver) {
|
||
const expected = maneuver === "u_turn" ? "left" : maneuver;
|
||
if (osmTurns && !osmTurns.has(expected) && !(maneuver === "u_turn" && osmTurns.has("u_turn"))) return false;
|
||
if (lane.allowedTurns.size && !lane.allowedTurns.has(expected) && !(maneuver === "u_turn" && lane.allowedTurns.has("u_turn"))) return false;
|
||
return true;
|
||
}
|
||
|
||
function laneKey(roadId, direction) {
|
||
return `${String(roadId)}:${direction}`;
|
||
}
|
||
|
||
function addDiagnostic(diagnostics, entry) {
|
||
const key = JSON.stringify(entry);
|
||
if (!diagnostics.some((current) => JSON.stringify(current) === key)) diagnostics.push(entry);
|
||
}
|
||
|
||
function indexIntersections(network, features) {
|
||
if (!Array.isArray(network.intersections)) throw new Error("Invalid osm2streets network: expected intersections");
|
||
const surfaces = new Map(features
|
||
.filter((feature) => feature?.geometry?.type === "Polygon" && Number.isInteger(Number(feature.properties?.id)))
|
||
.map((feature) => [Number(feature.properties.id), feature.geometry.coordinates[0]]));
|
||
const intersections = new Map();
|
||
for (const entry of network.intersections) {
|
||
const intersection = Array.isArray(entry) ? entry[1] : null;
|
||
if (!intersection || !Number.isInteger(Number(intersection.id))) continue;
|
||
intersections.set(Number(intersection.id), {
|
||
id: Number(intersection.id),
|
||
osmNodeIds: Array.isArray(intersection.osm_ids) ? intersection.osm_ids.map(String) : [],
|
||
surface: surfaces.get(Number(intersection.id)) || null,
|
||
});
|
||
}
|
||
return intersections;
|
||
}
|
||
|
||
function smoothLaneRoute(edges, selectedLanes, intersections) {
|
||
const route = [];
|
||
const connectors = [];
|
||
for (let index = 0; index < edges.length; index += 1) {
|
||
const current = selectedLanes[index].centerline;
|
||
appendCoordinates(route, current);
|
||
const nextIndex = (index + 1) % edges.length;
|
||
const next = selectedLanes[nextIndex].centerline;
|
||
const incomingEdge = edges[index];
|
||
const outgoingEdge = edges[nextIndex];
|
||
if (incomingEdge.endNode !== outgoingEdge.startNode) {
|
||
return { ok: false, reason: "disconnected_internal_roads", detail: { fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId } };
|
||
}
|
||
const intersection = intersections.get(incomingEdge.endNode);
|
||
if (!intersection?.surface) {
|
||
return { ok: false, reason: "missing_intersection_surface", detail: { intersectionId: incomingEdge.endNode } };
|
||
}
|
||
const isUTurn = incomingEdge.roadId === outgoingEdge.roadId;
|
||
const turn = constrainedConnector(current, next, incomingEdge.coordinates.at(-1), intersection.surface, isUTurn);
|
||
if (!turn) {
|
||
return {
|
||
ok: false,
|
||
reason: "connector_outside_intersection",
|
||
detail: { intersectionId: intersection.id, fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId },
|
||
};
|
||
}
|
||
appendCoordinates(route, turn.slice(1));
|
||
connectors.push({
|
||
intersectionId: intersection.id,
|
||
osmNodeIds: intersection.osmNodeIds,
|
||
fromRoad: incomingEdge.roadId,
|
||
toRoad: outgoingEdge.roadId,
|
||
maneuver: isUTurn ? "u_turn" : classifyConnection(incomingEdge, outgoingEdge),
|
||
source: "intersection_surface_constrained",
|
||
coordinates: turn,
|
||
});
|
||
}
|
||
if (route.length) route[route.length - 1] = [...route[0]];
|
||
return { ok: true, coordinates: route, connectors };
|
||
}
|
||
|
||
function constrainedConnector(incoming, outgoing, junction, surface, isUTurn) {
|
||
const scales = isUTurn ? [1, 0.8, 0.6, 0.4, 0.25] : [1, 0.75, 0.5, 0.3, 0.15];
|
||
for (const scale of scales) {
|
||
const connector = isUTurn
|
||
? uTurnConnector(incoming, outgoing, junction, 20, scale)
|
||
: tangentBezierTurn(incoming, outgoing, 16, scale);
|
||
if (connector.length && connector.every((point) => pointInPolygonOrNear(point, surface, CONNECTOR_SURFACE_TOLERANCE_METERS))) {
|
||
return connector;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function pointInPolygonOrNear(point, ring, toleranceMeters) {
|
||
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||
let inside = false;
|
||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||
const a = ring[i];
|
||
const b = ring[j];
|
||
if ((a[1] > point[1]) !== (b[1] > point[1]) &&
|
||
point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
|
||
if (distanceToSegmentMeters(point, a, b) <= toleranceMeters) return true;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
function distanceToSegmentMeters(point, start, end) {
|
||
const latitude = (point[1] + start[1] + end[1]) / 3;
|
||
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
|
||
const dx = (end[0] - start[0]) * metersLon;
|
||
const dy = (end[1] - start[1]) * 111320;
|
||
const px = (point[0] - start[0]) * metersLon;
|
||
const py = (point[1] - start[1]) * 111320;
|
||
const lengthSquared = dx * dx + dy * dy;
|
||
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
|
||
return Math.hypot(px - dx * ratio, py - dy * ratio);
|
||
}
|
||
|
||
function smoothRoute(edges) {
|
||
const trimmed = edges.map((edge) => trimPolyline(edge.coordinates, JUNCTION_TRIM_METERS));
|
||
const route = [];
|
||
for (let index = 0; index < edges.length; index += 1) {
|
||
appendCoordinates(route, trimmed[index]);
|
||
const nextIndex = (index + 1) % edges.length;
|
||
const turn = edges[index].roadId === edges[nextIndex].roadId
|
||
? uTurnConnector(trimmed[index], trimmed[nextIndex], edges[index].coordinates.at(-1))
|
||
: tangentBezierTurn(trimmed[index], trimmed[nextIndex]);
|
||
appendCoordinates(route, turn.slice(1));
|
||
}
|
||
if (route.length) route[route.length - 1] = [...route[0]];
|
||
return route;
|
||
}
|
||
|
||
function tangentBezierTurn(incoming, outgoing, samples = 16, scale = 1) {
|
||
if (incoming.length < 2 || outgoing.length < 2) return [];
|
||
const start = incoming.at(-1);
|
||
const end = outgoing[0];
|
||
const incomingTangent = directionVector(incoming.at(-2), start);
|
||
const outgoingTangent = directionVector(end, outgoing[1]);
|
||
const incomingSpan = haversineMeters(incoming.at(-2), start);
|
||
const outgoingSpan = haversineMeters(end, outgoing[1]);
|
||
const intersection = intersectTangentRays(start, end, incomingTangent, outgoingTangent);
|
||
let controlA;
|
||
let controlB;
|
||
if (intersection && intersection.a >= 0 && intersection.b >= 0) {
|
||
// 两条车道切线的前向交点定义了转弯的几何目标,Bezier 控制点取三分之一距离。
|
||
const maxA = Math.min(8, Math.max(0.75, incomingSpan * 2.4));
|
||
const maxB = Math.min(8, Math.max(0.75, outgoingSpan * 2.4));
|
||
const distanceA = Math.min(intersection.a, maxA) * scale;
|
||
const distanceB = Math.min(intersection.b, maxB) * scale;
|
||
controlA = offsetCoordinate(start, incomingTangent.x * distanceA / 3, incomingTangent.y * distanceA / 3);
|
||
controlB = offsetCoordinate(end, -outgoingTangent.x * distanceB / 3, -outgoingTangent.y * distanceB / 3);
|
||
} else {
|
||
// 平行、反向或交点在车道后方时,使用受限 fallback,避免生成反向回环。
|
||
const chordMeters = haversineMeters(start, end);
|
||
const controlMeters = boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, 0.42, 8) * scale;
|
||
controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
|
||
controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
|
||
}
|
||
return cubicBezier(start, controlA, controlB, end, samples);
|
||
}
|
||
|
||
function intersectTangentRays(start, end, incomingTangent, outgoingTangent) {
|
||
const latitude = (start[1] + end[1]) / 2;
|
||
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
|
||
const qx = (end[0] - start[0]) * metersLon;
|
||
const qy = (end[1] - start[1]) * 111320;
|
||
const cross = incomingTangent.x * outgoingTangent.y - incomingTangent.y * outgoingTangent.x;
|
||
if (Math.abs(cross) < 1e-6) return null;
|
||
const crossQOutgoing = qx * outgoingTangent.y - qy * outgoingTangent.x;
|
||
const crossQIncoming = qx * incomingTangent.y - qy * incomingTangent.x;
|
||
return {
|
||
a: crossQOutgoing / cross,
|
||
b: crossQIncoming / cross,
|
||
};
|
||
}
|
||
|
||
function uTurnConnector(incoming, outgoing, junction, samples = 20, scale = 1) {
|
||
if (incoming.length < 2 || outgoing.length < 2) return [];
|
||
const start = incoming.at(-1);
|
||
const end = outgoing[0];
|
||
const incomingTangent = directionVector(incoming.at(-2), start);
|
||
const outgoingTangent = directionVector(end, outgoing[1]);
|
||
const chordMeters = haversineMeters(start, end);
|
||
const approachMeters = Math.max(haversineMeters(start, junction), haversineMeters(end, junction));
|
||
const incomingSpan = haversineMeters(incoming.at(-2), start);
|
||
const outgoingSpan = haversineMeters(end, outgoing[1]);
|
||
const availableMeters = Math.max(0.5, Math.min(10, incomingSpan * 0.8, outgoingSpan * 0.8));
|
||
const controlMeters = Math.min(availableMeters, Math.max(Math.min(2, availableMeters), chordMeters * 1.1, approachMeters * 0.6)) * scale;
|
||
const controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
|
||
const controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
|
||
return cubicBezier(start, controlA, controlB, end, samples);
|
||
}
|
||
|
||
function boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, ratio, maximumMeters) {
|
||
const lowerMeters = Math.min(1.5, chordMeters * 0.35);
|
||
const upperMeters = Math.max(0.25, Math.min(maximumMeters, chordMeters * 0.65, incomingSpan * 0.8, outgoingSpan * 0.8));
|
||
return Math.min(upperMeters, Math.max(lowerMeters, chordMeters * ratio));
|
||
}
|
||
|
||
function offsetCoordinate(coord, eastMeters, northMeters) {
|
||
const metersPerLat = 111320;
|
||
const metersPerLon = metersPerLat * Math.cos(degreesToRadians(coord[1]));
|
||
return [coord[0] + eastMeters / metersPerLon, coord[1] + northMeters / metersPerLat];
|
||
}
|
||
|
||
function trimPolyline(coords, distance) {
|
||
if (coords.length < 2 || routeLength(coords) <= distance * 2.5) return [...coords];
|
||
const start = pointAlong(coords, distance);
|
||
const end = pointAlong([...coords].reverse(), distance);
|
||
return [start, ...coords.slice(1, -1), end];
|
||
}
|
||
|
||
function pointAlong(coords, distance) {
|
||
let remaining = distance;
|
||
for (let index = 1; index < coords.length; index += 1) {
|
||
const span = haversineMeters(coords[index - 1], coords[index]);
|
||
if (span >= remaining) return lerpCoordinate(coords[index - 1], coords[index], remaining / span);
|
||
remaining -= span;
|
||
}
|
||
return [...coords.at(-1)];
|
||
}
|
||
|
||
function cubicBezier(start, controlA, controlB, end, samples) {
|
||
const points = [];
|
||
for (let index = 0; index <= samples; index += 1) {
|
||
const t = index / samples;
|
||
const u = 1 - t;
|
||
points.push([
|
||
u ** 3 * start[0] + 3 * u ** 2 * t * controlA[0] + 3 * u * t ** 2 * controlB[0] + t ** 3 * end[0],
|
||
u ** 3 * start[1] + 3 * u ** 2 * t * controlA[1] + 3 * u * t ** 2 * controlB[1] + t ** 3 * end[1],
|
||
]);
|
||
}
|
||
return points;
|
||
}
|
||
|
||
function selectRoutes(candidates) {
|
||
const selected = [];
|
||
const covered = new Set();
|
||
const remaining = [...candidates];
|
||
while (selected.length < MAX_ROUTES && remaining.length) {
|
||
remaining.sort((a, b) => routeScore(b, covered) - routeScore(a, covered) || a.id.localeCompare(b.id));
|
||
const next = remaining.shift();
|
||
selected.push(next);
|
||
for (const maneuver of next.maneuvers) covered.add(maneuver);
|
||
}
|
||
return selected;
|
||
}
|
||
|
||
function routeScore(route, covered) {
|
||
const novelty = new Set(route.maneuvers.filter((maneuver) => ALL_TURNS.has(maneuver) && !covered.has(maneuver))).size;
|
||
return novelty * 100000 + route.lengthMeters;
|
||
}
|
||
|
||
function routeLength(coords) {
|
||
return polylineLength(coords);
|
||
}
|
||
|
||
function lerpCoordinate(a, b, t) {
|
||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||
}
|
||
|
||
function degreesToRadians(value) {
|
||
return value * Math.PI / 180;
|
||
}
|
||
|
||
module.exports = {
|
||
allowedTurns,
|
||
buildVehicleRoute,
|
||
classifyConnection,
|
||
readLanePolygons,
|
||
selectLaneForEdge,
|
||
tangentBezierTurn,
|
||
turnLanes,
|
||
uTurnConnector,
|
||
};
|