fix: 修正 Cesium 巡航车道中心对齐
This commit is contained in:
@@ -2,31 +2,120 @@
|
||||
|
||||
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 LANE_OFFSET_METERS = 1.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) {
|
||||
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 edges = directedRoadEdges(osm.ways, osm.nodes, osm.bounds);
|
||||
const routes = selectRoutes(findReturnRoutes(edges));
|
||||
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,
|
||||
// Older previews read `segments`; keep it as an alias while new previews
|
||||
// use the more accurate route name.
|
||||
segments: routes,
|
||||
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;
|
||||
@@ -36,70 +125,100 @@ function isCruiseHighway(tags) {
|
||||
]).has(highway);
|
||||
}
|
||||
|
||||
function directedRoadEdges(ways, nodes, bounds) {
|
||||
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 way of ways) {
|
||||
if (!isCruiseHighway(way.tags)) continue;
|
||||
const refs = compactRefs(way.refs);
|
||||
if (refs.length < 2) continue;
|
||||
const coords = refs.map((ref) => nodes.get(ref));
|
||||
if (!routeInsideBounds(coords, bounds) || routeLength(coords) < 12) continue;
|
||||
const oneway = String(way.tags.oneway || "").toLowerCase();
|
||||
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
|
||||
if (!isOneWay(oneway)) {
|
||||
edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
|
||||
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(way, refs, coordinates, direction) {
|
||||
function makeEdge(road, way, wayIds, coordinates, direction) {
|
||||
const forward = direction === "forward";
|
||||
const tags = way?.tags || {};
|
||||
return {
|
||||
id: `${way.id}:${direction}`,
|
||||
wayId: way.id,
|
||||
id: `road-${road.id}:${direction}`,
|
||||
roadId: Number(road.id),
|
||||
wayId: wayIds[0] || "",
|
||||
osmWayIds: wayIds,
|
||||
direction,
|
||||
name: way.tags.name || way.tags.highway || "road",
|
||||
highway: way.tags.highway || "",
|
||||
oneWay: way.tags.oneway || "",
|
||||
startNode: refs[0],
|
||||
endNode: refs[refs.length - 1],
|
||||
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(way.tags, direction),
|
||||
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 compactRefs(refs) {
|
||||
return refs.filter((ref, index) => index === 0 || ref !== refs[index - 1]);
|
||||
}
|
||||
|
||||
function routeInsideBounds(coords, bounds) {
|
||||
if (!bounds) return true;
|
||||
return coords.some((coord) => insideBounds(coord, bounds));
|
||||
}
|
||||
|
||||
function insideBounds(coord, bounds) {
|
||||
const pad = 0.00002;
|
||||
return coord[0] >= bounds.minLon - pad && coord[0] <= bounds.maxLon + pad &&
|
||||
coord[1] >= bounds.minLat - pad && coord[1] <= bounds.maxLat + pad;
|
||||
}
|
||||
|
||||
function allowedTurns(tags, direction) {
|
||||
const value = tags[`turn:lanes:${direction}`] || tags["turn:lanes"];
|
||||
if (!value) return ALL_TURNS;
|
||||
const turns = new Set();
|
||||
for (const lane of String(value).split("|")) {
|
||||
for (const maneuver of lane.split(";")) {
|
||||
const normalized = maneuver.trim().replace(/^slight_/, "");
|
||||
if (ALL_TURNS.has(normalized)) turns.add(normalized);
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -110,14 +229,12 @@ function findReturnRoutes(edges) {
|
||||
}
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
for (const first of edges) {
|
||||
walkToTerminal([first], [], outgoing, byId, candidates, seen);
|
||||
}
|
||||
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[path.length - 1];
|
||||
const current = path.at(-1);
|
||||
if (path.length >= MIN_ROUTE_EDGES) {
|
||||
const route = returnRoute(path, maneuvers, byId);
|
||||
if (route && !seen.has(route.signature)) {
|
||||
@@ -139,7 +256,7 @@ function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
|
||||
}
|
||||
|
||||
function classifyConnection(incoming, outgoing) {
|
||||
if (incoming.wayId === outgoing.wayId) return null;
|
||||
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;
|
||||
@@ -151,7 +268,7 @@ function classifyConnection(incoming, outgoing) {
|
||||
}
|
||||
|
||||
function directionVector(a, b) {
|
||||
const scale = 111320.0;
|
||||
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;
|
||||
@@ -159,7 +276,7 @@ function directionVector(a, b) {
|
||||
}
|
||||
|
||||
function returnRoute(path, forwardManeuvers, byId) {
|
||||
const reverse = path.slice().reverse().map((edge) => byId.get(`${edge.wayId}:${oppositeDirection(edge.direction)}`));
|
||||
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) {
|
||||
@@ -167,67 +284,312 @@ function returnRoute(path, forwardManeuvers, byId) {
|
||||
if (!maneuver) return null;
|
||||
returnManeuvers.push(maneuver);
|
||||
}
|
||||
const signature = path.map((edge) => edge.wayId).sort().join(">");
|
||||
return makeRoute(
|
||||
[...path, ...reverse],
|
||||
[...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
|
||||
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(edges, maneuvers, signature) {
|
||||
const coordinates = smoothRoute(edges);
|
||||
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-${signature.replace(/[^\w]+/g, "-")}`,
|
||||
highway: edges[0].highway,
|
||||
oneWay: edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
|
||||
edgeIds: edges.map((edge) => edge.id),
|
||||
maneuvers,
|
||||
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),
|
||||
laneOffsetMeters: LANE_OFFSET_METERS,
|
||||
coordinates: offsetClosedRouteRight(coordinates, LANE_OFFSET_METERS),
|
||||
centerlineCoordinates: 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: signature });
|
||||
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 junction = edges[index].coordinates.at(-1);
|
||||
const turn = edges[index].wayId === edges[nextIndex].wayId
|
||||
? uTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0])
|
||||
: bezierTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0], 6);
|
||||
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 uTurn(start, junction, end) {
|
||||
const tangent = directionVector(start, junction);
|
||||
const left = offsetCoordinate(junction, -tangent.y * 3.0, tangent.x * 3.0);
|
||||
const right = offsetCoordinate(junction, tangent.y * 3.0, -tangent.x * 3.0);
|
||||
return [
|
||||
start,
|
||||
lerpCoordinate(start, junction, 0.72),
|
||||
left,
|
||||
right,
|
||||
lerpCoordinate(end, junction, 0.72),
|
||||
end,
|
||||
];
|
||||
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.0;
|
||||
const metersPerLat = 111320;
|
||||
const metersPerLon = metersPerLat * Math.cos(degreesToRadians(coord[1]));
|
||||
return [coord[0] + eastMeters / metersPerLon, coord[1] + northMeters / metersPerLat];
|
||||
}
|
||||
@@ -249,9 +611,7 @@ function pointAlong(coords, distance) {
|
||||
return [...coords.at(-1)];
|
||||
}
|
||||
|
||||
function bezierTurn(start, junction, end, samples) {
|
||||
const controlA = lerpCoordinate(start, junction, 0.72);
|
||||
const controlB = lerpCoordinate(end, junction, 0.72);
|
||||
function cubicBezier(start, controlA, controlB, end, samples) {
|
||||
const points = [];
|
||||
for (let index = 0; index <= samples; index += 1) {
|
||||
const t = index / samples;
|
||||
@@ -264,19 +624,6 @@ function bezierTurn(start, junction, end, samples) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function appendCoordinates(target, coordinates) {
|
||||
for (const coord of coordinates) {
|
||||
const last = target.at(-1);
|
||||
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) target.push([...coord]);
|
||||
}
|
||||
}
|
||||
|
||||
function offsetClosedRouteRight(coords, offset) {
|
||||
const shifted = offsetPolylineRight(coords, offset);
|
||||
if (shifted.length) shifted[shifted.length - 1] = [...shifted[0]];
|
||||
return shifted;
|
||||
}
|
||||
|
||||
function selectRoutes(candidates) {
|
||||
const selected = [];
|
||||
const covered = new Set();
|
||||
@@ -295,43 +642,25 @@ function routeScore(route, covered) {
|
||||
return novelty * 100000 + route.lengthMeters;
|
||||
}
|
||||
|
||||
function offsetPolylineRight(coords, offsetMeters) {
|
||||
if (coords.length < 2 || offsetMeters === 0) return coords.map((coord) => [...coord]);
|
||||
const refLat = coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length;
|
||||
const metersPerLat = 111320.0;
|
||||
const metersPerLon = 111320.0 * Math.cos(degreesToRadians(refLat));
|
||||
const points = coords.map((coord) => ({ x: coord[0] * metersPerLon, y: coord[1] * metersPerLat, lon: coord[0], lat: coord[1] }));
|
||||
return points.map((point, index) => {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
const length = Math.hypot(next.x - prev.x, next.y - prev.y);
|
||||
if (length < 0.001) return [point.lon, point.lat];
|
||||
const dx = (next.x - prev.x) / length;
|
||||
const dy = (next.y - prev.y) / length;
|
||||
return [(point.x + dy * offsetMeters) / metersPerLon, (point.y - dx * offsetMeters) / metersPerLat];
|
||||
});
|
||||
}
|
||||
|
||||
function routeLength(coords) {
|
||||
let total = 0;
|
||||
for (let index = 1; index < coords.length; index += 1) total += haversineMeters(coords[index - 1], coords[index]);
|
||||
return total;
|
||||
}
|
||||
|
||||
function haversineMeters(a, b) {
|
||||
const radius = 6371008.8;
|
||||
const lat1 = degreesToRadians(a[1]);
|
||||
const lat2 = degreesToRadians(b[1]);
|
||||
const dLat = degreesToRadians(b[1] - a[1]);
|
||||
const dLon = degreesToRadians(b[0] - a[0]);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
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; }
|
||||
function degreesToRadians(value) {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
module.exports = { buildVehicleRoute, classifyConnection, allowedTurns };
|
||||
module.exports = {
|
||||
allowedTurns,
|
||||
buildVehicleRoute,
|
||||
classifyConnection,
|
||||
readLanePolygons,
|
||||
selectLaneForEdge,
|
||||
tangentBezierTurn,
|
||||
turnLanes,
|
||||
uTurnConnector,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user