fix: 修正 Cesium 巡航车道中心对齐
This commit is contained in:
@@ -493,11 +493,19 @@ function writeCesiumPreview(area) {
|
||||
ensureFile(area.outputs.glb, "Cesium GLB");
|
||||
ensureFile(area.outputs.metadata, "Cesium metadata");
|
||||
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
|
||||
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
|
||||
const network = path.join(area.outputs.geojsonDir, "network.json");
|
||||
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
|
||||
ensureFile(lanePolygons, "Driving lane polygons");
|
||||
ensureFile(network, "osm2streets network");
|
||||
ensureFile(intersectionSurface, "Intersection surfaces");
|
||||
// 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。
|
||||
const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
|
||||
const htmlPath = area.outputs.cesiumPreview;
|
||||
const started = Date.now();
|
||||
const startedAt = new Date(started).toISOString();
|
||||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||||
writeVehicleRoute(area);
|
||||
writeVehicleRoute(area, vehicleRoute);
|
||||
const vehicleModelNames = writeVehicleModel(area);
|
||||
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
|
||||
const glbName = path.basename(area.outputs.glb);
|
||||
@@ -519,6 +527,9 @@ function writeCesiumPreview(area) {
|
||||
osm: fileRecord(area.input),
|
||||
glb: fileRecord(area.outputs.glb),
|
||||
metadata: fileRecord(area.outputs.metadata),
|
||||
lanePolygons: fileRecord(lanePolygons),
|
||||
network: fileRecord(network),
|
||||
intersectionSurface: fileRecord(intersectionSurface),
|
||||
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
|
||||
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
|
||||
},
|
||||
@@ -543,8 +554,7 @@ function previewRelativePath(fromDir, target) {
|
||||
return path.relative(fromDir, target).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function writeVehicleRoute(area) {
|
||||
const route = buildPreviewVehicleRoute(area.input);
|
||||
function writeVehicleRoute(area, route) {
|
||||
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
|
||||
fs.writeFileSync(area.outputs.vehicleRoute, `${JSON.stringify(route, null, 2)}\n`);
|
||||
console.log(`Vehicle route: ${area.outputs.vehicleRoute}`);
|
||||
|
||||
@@ -1452,6 +1452,7 @@ from qgis.core import (
|
||||
QgsApplication,
|
||||
QgsCoordinateReferenceSystem,
|
||||
QgsEditorWidgetSetup,
|
||||
QgsFieldConstraints,
|
||||
QgsFillSymbol,
|
||||
QgsMarkerSymbol,
|
||||
QgsMapRendererCustomPainterJob,
|
||||
@@ -1481,6 +1482,11 @@ try:
|
||||
except AttributeError:
|
||||
IMAGE_FORMAT = QImage.Format_ARGB32_Premultiplied
|
||||
|
||||
try:
|
||||
NOT_NULL_CONSTRAINT = QgsFieldConstraints.Constraint.ConstraintNotNull
|
||||
except AttributeError:
|
||||
NOT_NULL_CONSTRAINT = QgsFieldConstraints.ConstraintNotNull
|
||||
|
||||
def fill_symbol(color, outline="0,0,0,0", outline_width="0"):
|
||||
return QgsFillSymbol.createSimple({
|
||||
"color": color,
|
||||
@@ -1517,7 +1523,7 @@ def make_signal_layer():
|
||||
for field_name in ("signal_uid", "control_id", "approach_id", "source_way_id", "stop_lon", "stop_lat"):
|
||||
index = layer.fields().indexOf(field_name)
|
||||
if index >= 0:
|
||||
layer.setFieldConstraint(index, 1)
|
||||
layer.setFieldConstraint(index, NOT_NULL_CONSTRAINT)
|
||||
form = layer.editFormConfig()
|
||||
form.setReadOnly(index, True)
|
||||
layer.setEditFormConfig(form)
|
||||
|
||||
@@ -459,6 +459,9 @@ function stageManifestStatus(area, configPath = null) {
|
||||
osm: area.input,
|
||||
glb: area.outputs.glb,
|
||||
metadata: area.outputs.metadata,
|
||||
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
|
||||
network: path.join(area.outputs.geojsonDir, "network.json"),
|
||||
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
|
||||
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
|
||||
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
|
||||
},
|
||||
|
||||
@@ -67,7 +67,9 @@
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
// Generated preview JSON keeps a stable filename; bypass browser caches so
|
||||
// route regeneration is visible immediately during inspection.
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not load " + url + ": " + response.status);
|
||||
}
|
||||
@@ -350,7 +352,7 @@
|
||||
setStatus(toggleScene.checked ? "Scene visible" : "Scene hidden");
|
||||
});
|
||||
toggleRoutes.addEventListener("change", () => {
|
||||
for (const vehicle of cruise.vehicles) vehicle.routeEntity.show = toggleRoutes.checked;
|
||||
syncSelectedRouteVisibility(cruise);
|
||||
});
|
||||
toggleVehicles.addEventListener("change", () => {
|
||||
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
|
||||
@@ -483,6 +485,7 @@
|
||||
});
|
||||
vehicleSelect.addEventListener("change", () => {
|
||||
cruise.state.selectedIndex = Number(vehicleSelect.value || 0);
|
||||
syncSelectedRouteVisibility(cruise);
|
||||
setStatus(selectedVehicle(cruise).label);
|
||||
});
|
||||
|
||||
@@ -527,11 +530,19 @@
|
||||
vehicleSelect.appendChild(option);
|
||||
return vehicle;
|
||||
});
|
||||
return {
|
||||
const cruise = {
|
||||
vehicles,
|
||||
baseSpeed: speed,
|
||||
state: { selectedIndex: 0 }
|
||||
};
|
||||
syncSelectedRouteVisibility(cruise);
|
||||
return cruise;
|
||||
}
|
||||
|
||||
function syncSelectedRouteVisibility(cruise) {
|
||||
for (let index = 0; index < cruise.vehicles.length; index += 1) {
|
||||
cruise.vehicles[index].routeEntity.show = toggleRoutes.checked && index === cruise.state.selectedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
function addTrafficSignals(viewer, signalData, start, assets) {
|
||||
|
||||
161
scripts/lib/lane-geometry.js
Normal file
161
scripts/lib/lane-geometry.js
Normal file
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
|
||||
function laneCenterline(lane) {
|
||||
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
|
||||
if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null;
|
||||
const vertices = ring.slice(0, -1);
|
||||
if (!vertices.every(validCoordinate)) return null;
|
||||
const half = vertices.length / 2;
|
||||
if (!Number.isInteger(half) || half < 2) return null;
|
||||
const centerline = vertices.slice(0, half).map((point, index) => [
|
||||
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
|
||||
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
|
||||
]);
|
||||
return polylineLength(centerline) > 0.01 ? centerline : null;
|
||||
}
|
||||
|
||||
function orientPolyline(polyline, reference) {
|
||||
if (!polyline?.length || !reference?.length) return null;
|
||||
const forward = projectedDistanceAlong(reference, polyline.at(-1)) - projectedDistanceAlong(reference, polyline[0]);
|
||||
if (Math.abs(forward) < 0.01) return null;
|
||||
return forward > 0 ? polyline.map(copyCoordinate) : [...polyline].reverse().map(copyCoordinate);
|
||||
}
|
||||
|
||||
function stitchPolylines(polylines, maxGapMeters) {
|
||||
if (!polylines.length) return null;
|
||||
const result = [];
|
||||
for (const polyline of polylines) {
|
||||
if (!polyline?.length) return null;
|
||||
if (result.length && haversineMeters(result.at(-1), polyline[0]) > maxGapMeters) return null;
|
||||
appendCoordinates(result, polyline);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectedDistanceAlong(polyline, point) {
|
||||
let traversed = 0;
|
||||
let best = { distance: Infinity, along: 0, lateral: 0 };
|
||||
for (let index = 1; index < polyline.length; index += 1) {
|
||||
const start = polyline[index - 1];
|
||||
const end = polyline[index];
|
||||
const meters = metersAt((start[1] + end[1]) / 2);
|
||||
const dx = (end[0] - start[0]) * meters.lon;
|
||||
const dy = (end[1] - start[1]) * meters.lat;
|
||||
const px = (point[0] - start[0]) * meters.lon;
|
||||
const py = (point[1] - start[1]) * meters.lat;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 0.001) continue;
|
||||
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
|
||||
const offsetX = px - dx * ratio;
|
||||
const offsetY = py - dy * ratio;
|
||||
const distance = Math.hypot(offsetX, offsetY);
|
||||
if (distance < best.distance) {
|
||||
const rightX = dy / length;
|
||||
const rightY = -dx / length;
|
||||
best = {
|
||||
distance,
|
||||
along: traversed + length * ratio,
|
||||
lateral: offsetX * rightX + offsetY * rightY,
|
||||
};
|
||||
}
|
||||
traversed += length;
|
||||
}
|
||||
return best.along;
|
||||
}
|
||||
|
||||
function lateralOffsetFrom(polyline, point) {
|
||||
let best = null;
|
||||
for (let index = 1; index < polyline.length; index += 1) {
|
||||
const start = polyline[index - 1];
|
||||
const end = polyline[index];
|
||||
const meters = metersAt((start[1] + end[1]) / 2);
|
||||
const dx = (end[0] - start[0]) * meters.lon;
|
||||
const dy = (end[1] - start[1]) * meters.lat;
|
||||
const px = (point[0] - start[0]) * meters.lon;
|
||||
const py = (point[1] - start[1]) * meters.lat;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length < 0.001) continue;
|
||||
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
|
||||
const offsetX = px - dx * ratio;
|
||||
const offsetY = py - dy * ratio;
|
||||
const distance = Math.hypot(offsetX, offsetY);
|
||||
if (!best || distance < best.distance) {
|
||||
best = { distance, lateral: offsetX * dy / length - offsetY * dx / length };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function polylineMidpoint(polyline) {
|
||||
const target = polylineLength(polyline) / 2;
|
||||
let traversed = 0;
|
||||
for (let index = 1; index < polyline.length; index += 1) {
|
||||
const length = haversineMeters(polyline[index - 1], polyline[index]);
|
||||
if (traversed + length >= target) {
|
||||
const ratio = length ? (target - traversed) / length : 0;
|
||||
return [
|
||||
polyline[index - 1][0] + (polyline[index][0] - polyline[index - 1][0]) * ratio,
|
||||
polyline[index - 1][1] + (polyline[index][1] - polyline[index - 1][1]) * ratio,
|
||||
];
|
||||
}
|
||||
traversed += length;
|
||||
}
|
||||
return polyline.length ? copyCoordinate(polyline.at(-1)) : null;
|
||||
}
|
||||
|
||||
function polylineLength(polyline) {
|
||||
let total = 0;
|
||||
for (let index = 1; index < (polyline?.length || 0); index += 1) {
|
||||
total += haversineMeters(polyline[index - 1], polyline[index]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function haversineMeters(a, b) {
|
||||
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 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
function appendCoordinates(target, coordinates) {
|
||||
for (const coordinate of coordinates) {
|
||||
if (!sameCoordinate(target.at(-1), coordinate)) target.push(copyCoordinate(coordinate));
|
||||
}
|
||||
}
|
||||
|
||||
function validCoordinate(value) {
|
||||
return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]);
|
||||
}
|
||||
|
||||
function sameCoordinate(a, b) {
|
||||
return Boolean(a && b && a[0] === b[0] && a[1] === b[1]);
|
||||
}
|
||||
|
||||
function copyCoordinate(coordinate) {
|
||||
return [coordinate[0], coordinate[1]];
|
||||
}
|
||||
|
||||
function metersAt(latitude) {
|
||||
return { lon: 111320 * Math.cos(degreesToRadians(latitude)), lat: 111320 };
|
||||
}
|
||||
|
||||
function degreesToRadians(value) {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendCoordinates,
|
||||
haversineMeters,
|
||||
laneCenterline,
|
||||
lateralOffsetFrom,
|
||||
orientPolyline,
|
||||
polylineLength,
|
||||
polylineMidpoint,
|
||||
projectedDistanceAlong,
|
||||
stitchPolylines,
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { laneCenterline } = require("./lane-geometry");
|
||||
|
||||
const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "assets", "lane-icons", "manifest.json");
|
||||
const LANE_WIDTH_METERS = 3.2;
|
||||
@@ -262,23 +263,6 @@ function closestPointOnSegment(point, start, end, meters) {
|
||||
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
|
||||
}
|
||||
|
||||
function laneCenterline(lane) {
|
||||
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
|
||||
// A straight osm2streets Driving lane is commonly a closed quadrilateral:
|
||||
// four distinct vertices plus the repeated closing vertex. Its opposing
|
||||
// edges still provide the same two-point centerline as longer lane shapes.
|
||||
if (!ring || ring.length < 5) return null;
|
||||
// osm2streets Driving polygons are ordered along one boundary then back
|
||||
// along the other. Midpoints of paired vertices form the rendered lane axis.
|
||||
const vertices = ring.slice(0, -1);
|
||||
const half = vertices.length / 2;
|
||||
if (!Number.isInteger(half) || half < 2) return null;
|
||||
return vertices.slice(0, half).map((point, index) => [
|
||||
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
|
||||
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
|
||||
]);
|
||||
}
|
||||
|
||||
function axisForLane(ordered, meters) {
|
||||
return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -6,8 +6,14 @@ const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { normalizeAreaConfig } = require("./lib/area-config");
|
||||
const { stageManifestStatus } = require("./lib/area-diagnostics");
|
||||
const { digestGltf } = require("./glb-digest");
|
||||
const { evaluateGlbBudget, BUDGETS } = require("./lib/stage-manifest");
|
||||
const { evaluateGlbBudget, BUDGETS, fileRecord, writeStageManifest } = require("./lib/stage-manifest");
|
||||
|
||||
const qgisBuildSource = fs.readFileSync(path.join(__dirname, "build-osm2streets-qgis.js"), "utf8");
|
||||
assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNull/);
|
||||
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
|
||||
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
|
||||
|
||||
const gltf = {
|
||||
nodes: [
|
||||
@@ -61,6 +67,63 @@ assert.equal(
|
||||
normalizeAreaConfig({ ...base, budget: { nodes: 1200, reason: "Dense campus vegetation" } }).budget.glbNodes,
|
||||
1200,
|
||||
);
|
||||
|
||||
const configPath = path.join(tempDir, "area.json");
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`);
|
||||
const area = normalizeAreaConfig(base);
|
||||
fs.mkdirSync(area.outputs.geojsonDir, { recursive: true });
|
||||
for (const file of [area.outputs.glb, area.outputs.metadata, area.outputs.cesiumPreview, area.outputs.vehicleRoute, area.outputs.vehicleModel]) {
|
||||
fs.writeFileSync(file, "fixture\n");
|
||||
}
|
||||
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
|
||||
const emptyFeatureCollection = '{"type":"FeatureCollection","features":[]}\n';
|
||||
const emptyNetwork = '{"roads":[],"intersections":[],"gps_bounds":{}}\n';
|
||||
fs.writeFileSync(lanePolygons, emptyFeatureCollection);
|
||||
const network = path.join(area.outputs.geojsonDir, "network.json");
|
||||
fs.writeFileSync(network, emptyNetwork);
|
||||
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
|
||||
fs.writeFileSync(intersectionSurface, emptyFeatureCollection);
|
||||
const previewCss = path.join(__dirname, "lib", "cesium-preview.css");
|
||||
const previewJs = path.join(__dirname, "lib", "cesium-preview.js");
|
||||
writeStageManifest(area, {
|
||||
stage: "preview",
|
||||
status: "ok",
|
||||
config: configPath,
|
||||
inputs: {
|
||||
config: fileRecord(configPath),
|
||||
osm: fileRecord(input),
|
||||
glb: fileRecord(area.outputs.glb),
|
||||
metadata: fileRecord(area.outputs.metadata),
|
||||
lanePolygons: fileRecord(lanePolygons),
|
||||
network: fileRecord(network),
|
||||
intersectionSurface: fileRecord(intersectionSurface),
|
||||
previewCss: fileRecord(previewCss),
|
||||
previewJs: fileRecord(previewJs),
|
||||
},
|
||||
outputs: {
|
||||
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
|
||||
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
|
||||
vehicleModel: fileRecord(area.outputs.vehicleModel),
|
||||
},
|
||||
summary: {},
|
||||
warnings: [],
|
||||
});
|
||||
let previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
|
||||
assert.equal(previewManifest.fresh, true);
|
||||
fs.appendFileSync(lanePolygons, " \n");
|
||||
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
|
||||
assert.equal(previewManifest.fresh, false);
|
||||
assert.ok(previewManifest.issues.some((issue) => issue.includes("lanePolygons")));
|
||||
fs.writeFileSync(lanePolygons, emptyFeatureCollection);
|
||||
fs.appendFileSync(network, " \n");
|
||||
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
|
||||
assert.equal(previewManifest.fresh, false);
|
||||
assert.ok(previewManifest.issues.some((issue) => issue.includes("network")));
|
||||
fs.writeFileSync(network, emptyNetwork);
|
||||
fs.appendFileSync(intersectionSurface, " \n");
|
||||
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
|
||||
assert.equal(previewManifest.fresh, false);
|
||||
assert.ok(previewManifest.issues.some((issue) => issue.includes("intersectionSurface")));
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
console.log("Asset budget tests passed.");
|
||||
|
||||
@@ -8,12 +8,22 @@ const path = require("path");
|
||||
const { cesiumPreviewHtml } = require("./lib/area-preview");
|
||||
const { makeVehicleGltf } = require("./lib/vehicle-model");
|
||||
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
|
||||
const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route");
|
||||
const {
|
||||
allowedTurns,
|
||||
buildVehicleRoute,
|
||||
classifyConnection,
|
||||
tangentBezierTurn,
|
||||
uTurnConnector,
|
||||
} = require("./lib/vehicle-route");
|
||||
const { buildTrafficSignals } = require("./lib/traffic-signals");
|
||||
const { haversineMeters, laneCenterline } = require("./lib/lane-geometry");
|
||||
const { parseOsm } = require("./lib/osm");
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
||||
const osmPath = path.join(tempDir, "fixture.osm");
|
||||
const lanePolygonsPath = path.join(tempDir, "lane_polygons.geojson");
|
||||
const networkPath = path.join(tempDir, "network.json");
|
||||
const intersectionSurfacePath = path.join(tempDir, "intersection_surface.geojson");
|
||||
|
||||
fs.writeFileSync(osmPath, `<?xml version="1.0"?>
|
||||
<osm version="0.6">
|
||||
@@ -25,11 +35,11 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
|
||||
<node id="5" lon="120.009" lat="30.002"/>
|
||||
<way id="west-road">
|
||||
<nd ref="1"/><nd ref="2"/>
|
||||
<tag k="highway" v="primary"/><tag k="turn:lanes:forward" v="left|through"/>
|
||||
<tag k="highway" v="primary"/><tag k="lanes" v="4"/><tag k="lanes:forward" v="2"/><tag k="lanes:backward" v="2"/><tag k="turn:lanes:forward" v="left|through"/>
|
||||
</way>
|
||||
<way id="turn-road">
|
||||
<nd ref="2"/><nd ref="3"/>
|
||||
<tag k="highway" v="residential"/><tag k="turn:lanes:forward" v="through|left"/>
|
||||
<tag k="highway" v="residential"/><tag k="lanes" v="3"/><tag k="lanes:forward" v="2"/><tag k="lanes:backward" v="1"/><tag k="turn:lanes:forward" v="through|left"/><tag k="turn:lanes:backward" v="right"/>
|
||||
</way>
|
||||
<way id="east-road">
|
||||
<nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/>
|
||||
@@ -43,8 +53,44 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
|
||||
</osm>
|
||||
`);
|
||||
|
||||
const route = buildVehicleRoute(osmPath);
|
||||
const roadCoordinates = {
|
||||
"west-road": [[120.001, 30.001], [120.003, 30.001]],
|
||||
"turn-road": [[120.003, 30.001], [120.005, 30.002]],
|
||||
"east-road": [[120.005, 30.002], [120.007, 30.002]],
|
||||
};
|
||||
const laneFeatures = [
|
||||
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Back", 3.5, [5.25, 1.75], 0),
|
||||
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Fwd", 3.5, [1.75, 5.25], 2),
|
||||
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Back", 3.0, [1.5], 0),
|
||||
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Fwd", 3.0, [1.5, 4.5], 1),
|
||||
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Back", 3.5, [1.75], 0),
|
||||
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Fwd", 3.5, [1.75], 1),
|
||||
{ type: "Feature", properties: { type: "Driving", direction: "Fwd", index: 99, width: 3, road: 99, osm_way_ids: ["broken"] }, geometry: { type: "Polygon", coordinates: [[]] } },
|
||||
];
|
||||
fs.writeFileSync(lanePolygonsPath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures })}\n`);
|
||||
const fixtureBounds = { min_lon: 120, min_lat: 30, max_lon: 120.01, max_lat: 30.01 };
|
||||
const networkRoads = [
|
||||
networkRoad(0, "west-road", 0, 1, roadCoordinates["west-road"], [laneSpec("Back", 3.5), laneSpec("Back", 3.5), laneSpec("Fwd", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds, "primary"),
|
||||
networkRoad(1, "turn-road", 1, 2, roadCoordinates["turn-road"], [laneSpec("Back", 3), laneSpec("Fwd", 3), laneSpec("Fwd", 3)], fixtureBounds),
|
||||
networkRoad(2, "east-road", 2, 3, roadCoordinates["east-road"], [laneSpec("Back", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds),
|
||||
networkRoad(3, "oneway-spur", 3, 4, [[120.007, 30.002], [120.009, 30.002]], [laneSpec("Fwd", 3.5)], fixtureBounds),
|
||||
];
|
||||
fs.writeFileSync(networkPath, `${JSON.stringify({
|
||||
roads: networkRoads.map((road) => [road.id, road]),
|
||||
intersections: [0, 1, 2, 3, 4].map((id) => [id, { id, osm_ids: [String(id + 1)] }]),
|
||||
gps_bounds: fixtureBounds,
|
||||
})}\n`);
|
||||
fs.writeFileSync(intersectionSurfacePath, `${JSON.stringify({
|
||||
type: "FeatureCollection",
|
||||
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
|
||||
.map((coordinate, id) => intersectionFeature(id, coordinate, 9)),
|
||||
})}\n`);
|
||||
|
||||
const route = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath);
|
||||
assert.equal(route.source, osmPath);
|
||||
assert.equal(route.laneSource, lanePolygonsPath);
|
||||
assert.equal(route.networkSource, networkPath);
|
||||
assert.equal(route.intersectionSource, intersectionSurfacePath);
|
||||
assert.deepEqual(route.bounds, {
|
||||
minLon: 120,
|
||||
minLat: 30,
|
||||
@@ -59,16 +105,97 @@ assert.ok(route.routes.every((segment) => segment.edgeIds.length >= 6));
|
||||
assert.ok(route.routes.every((segment) => segment.maneuvers.includes("u_turn")));
|
||||
assert.ok(route.routes.every((segment) => segment.maneuvers.some((value) => ["left", "right", "through"].includes(value))));
|
||||
assert.ok(route.routes.every((segment) => JSON.stringify(segment.coordinates[0]) === JSON.stringify(segment.coordinates.at(-1))));
|
||||
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("oneway-spur:backward")));
|
||||
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("road-3:backward")));
|
||||
assert.notDeepEqual(route.routes[0].coordinates, route.routes[0].centerlineCoordinates);
|
||||
assert.ok(route.routes.every((segment) => !Object.hasOwn(segment, "laneOffsetMeters")));
|
||||
assert.ok(route.routes.every((segment) => segment.laneSegments.length >= segment.edgeIds.length));
|
||||
assert.ok(route.routes.every((segment) => segment.connectors.length === segment.edgeIds.length));
|
||||
assert.ok(route.routes.flatMap((segment) => segment.connectors).every((connector) =>
|
||||
connector.source === "intersection_surface_constrained" && connector.coordinates.length >= 2
|
||||
));
|
||||
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3));
|
||||
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3.5));
|
||||
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).every((segment) =>
|
||||
segment.source === "lane_polygon_centerline" && Number.isFinite(segment.centerOffsetMeters)
|
||||
));
|
||||
for (const segment of route.routes) {
|
||||
for (const lane of segment.laneSegments) {
|
||||
const polygonCenterline = laneCenterline(laneFeatures[lane.featureIndex]);
|
||||
assert.ok(polygonCenterline, "selected lane polygon has a valid centerline");
|
||||
assert.ok(polygonCenterline.every((point) =>
|
||||
Math.min(...segment.coordinates.map((coordinate) => haversineMeters(point, coordinate))) <= 0.10
|
||||
), "route coordinates retain every selected lane centerline point within 0.10 m");
|
||||
}
|
||||
}
|
||||
assert.ok(route.diagnostics.some((entry) => entry.reason === "invalid_lane_polygon"));
|
||||
const westThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
|
||||
lane.osmWayId === "west-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 3
|
||||
);
|
||||
assert.ok(westThrough, "through uses the rightmost compatible lane on west-road");
|
||||
assert.ok(Math.abs(westThrough.centerOffsetMeters - 5.25) <= 0.01, "3.5 m lane geometry produces the 5.25 m outer-lane center");
|
||||
const turnThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
|
||||
lane.osmWayId === "turn-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 1
|
||||
);
|
||||
assert.ok(turnThrough, "turn lane restrictions override the default rightmost choice");
|
||||
assert.ok(Math.abs(turnThrough.centerOffsetMeters - 1.5) <= 0.01, "3.0 m lane geometry produces the 1.5 m inner-lane center");
|
||||
assert.ok(route.routes.some((segment) => segment.laneSegments.some((lane) =>
|
||||
lane.osmWayId === "turn-road" && lane.direction === "backward" && lane.maneuver === "through"
|
||||
)), "display return survives an incompatible reverse turn:lanes tag");
|
||||
const missingLanePath = path.join(tempDir, "missing-lane.geojson");
|
||||
fs.writeFileSync(missingLanePath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures.filter((feature) =>
|
||||
!feature.properties.osm_way_ids.includes("east-road")
|
||||
) })}\n`);
|
||||
const missingLaneRoute = buildVehicleRoute(osmPath, missingLanePath, networkPath, intersectionSurfacePath);
|
||||
assert.equal(missingLaneRoute.routes.length, 0);
|
||||
assert.ok(missingLaneRoute.diagnostics.some((entry) => entry.reason === "missing_lane_polygon"));
|
||||
const tinyIntersectionSurfacePath = path.join(tempDir, "tiny-intersection-surface.geojson");
|
||||
fs.writeFileSync(tinyIntersectionSurfacePath, `${JSON.stringify({
|
||||
type: "FeatureCollection",
|
||||
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
|
||||
.map((coordinate, id) => intersectionFeature(id, coordinate, 0.1)),
|
||||
})}\n`);
|
||||
const rejectedConnectors = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, tinyIntersectionSurfacePath);
|
||||
assert.equal(rejectedConnectors.routes.length, 0);
|
||||
assert.ok(rejectedConnectors.diagnostics.some((entry) => entry.reason === "connector_outside_intersection"));
|
||||
assert.throws(() => buildVehicleRoute(osmPath, path.join(tempDir, "absent.geojson"), networkPath, intersectionSurfacePath), /Invalid lane polygons JSON/);
|
||||
const quad = laneFeatures[0];
|
||||
assert.equal(laneCenterline(quad).length, 2);
|
||||
assert.equal(laneCenterline({ geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [0, 0]]] } }), null);
|
||||
assert.deepEqual(
|
||||
[...allowedTurns({ "turn:lanes:forward": "left|through;right" }, "forward")].sort(),
|
||||
["left", "right", "through"],
|
||||
);
|
||||
const incoming = { wayId: "in", coordinates: [[120, 30], [120.001, 30]] };
|
||||
assert.equal(classifyConnection(incoming, { wayId: "left", coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
|
||||
assert.equal(classifyConnection(incoming, { wayId: "right", coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
|
||||
assert.equal(classifyConnection(incoming, { wayId: "through", coordinates: [[120.001, 30], [120.002, 30]] }), "through");
|
||||
const incoming = { roadId: 1, coordinates: [[120, 30], [120.001, 30]] };
|
||||
assert.equal(classifyConnection(incoming, { roadId: 2, coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
|
||||
assert.equal(classifyConnection(incoming, { roadId: 3, coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
|
||||
assert.equal(classifyConnection(incoming, { roadId: 4, coordinates: [[120.001, 30], [120.002, 30]] }), "through");
|
||||
|
||||
const connectorOrigin = [120, 30];
|
||||
const incomingLane = [metersCoordinate(connectorOrigin, -12, -1.5), metersCoordinate(connectorOrigin, -5, -1.5)];
|
||||
const leftOutgoingLane = [metersCoordinate(connectorOrigin, 1.5, 5), metersCoordinate(connectorOrigin, 1.5, 12)];
|
||||
const rightOutgoingLane = [metersCoordinate(connectorOrigin, -1.5, -5), metersCoordinate(connectorOrigin, -1.5, -12)];
|
||||
for (const [label, outgoingLane] of [["left", leftOutgoingLane], ["right", rightOutgoingLane]]) {
|
||||
const connector = tangentBezierTurn(incomingLane, outgoingLane);
|
||||
assert.deepEqual(connector[0], incomingLane.at(-1), `${label} connector retains the incoming lane endpoint`);
|
||||
assert.deepEqual(connector.at(-1), outgoingLane[0], `${label} connector retains the outgoing lane endpoint`);
|
||||
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), connector[0], connector[1]) < 5,
|
||||
`${label} connector enters along the incoming lane tangent`);
|
||||
assert.ok(tangentMismatchDegrees(connector.at(-2), connector.at(-1), outgoingLane[0], outgoingLane[1]) < 5,
|
||||
`${label} connector exits along the outgoing lane tangent`);
|
||||
assert.ok(maxStepMeters(connector) < 1.5, `${label} connector sampling has no abnormal position jump`);
|
||||
}
|
||||
|
||||
const uTurnOutgoingLane = [metersCoordinate(connectorOrigin, -5, 1.5), metersCoordinate(connectorOrigin, -12, 1.5)];
|
||||
const uTurn = uTurnConnector(incomingLane, uTurnOutgoingLane, connectorOrigin);
|
||||
assert.deepEqual(uTurn[0], incomingLane.at(-1));
|
||||
assert.deepEqual(uTurn.at(-1), uTurnOutgoingLane[0]);
|
||||
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), uTurn[0], uTurn[1]) < 5,
|
||||
"U-turn enters along the incoming lane tangent");
|
||||
assert.ok(tangentMismatchDegrees(uTurn.at(-2), uTurn.at(-1), uTurnOutgoingLane[0], uTurnOutgoingLane[1]) < 5,
|
||||
"U-turn exits along the outgoing lane tangent");
|
||||
assert.ok(maxStepMeters(uTurn) < 1, "U-turn sampling has no abnormal position jump");
|
||||
assert.ok(Math.max(...uTurn.map((coordinate) => eastMeters(connectorOrigin, coordinate))) > -3,
|
||||
"U-turn forms a forward loop instead of a fixed lateral polyline");
|
||||
|
||||
const vehicle = makeVehicleGltf();
|
||||
assert.equal(vehicle.asset.version, "2.0");
|
||||
@@ -139,6 +266,8 @@ assert.match(previewRuntime, /TrafficSignalDynamic_/);
|
||||
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
||||
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
|
||||
assert.match(previewRuntime, /setBuildingGhost/);
|
||||
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);
|
||||
assert.match(previewRuntime, /syncSelectedRouteVisibility\(cruise\)/);
|
||||
assert.match(previewRuntime, /buildings\.model\.color = Cesium\.Color\.WHITE\.withAlpha\(0\.22\)/);
|
||||
assert.match(previewRuntime, /asset\.category === "countdown"/);
|
||||
assert.doesNotMatch(previewRuntime, /createCountdownDigits/);
|
||||
@@ -205,3 +334,100 @@ function rectangle(lon, lat, halfWidth, halfHeight) {
|
||||
[lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight],
|
||||
]] } };
|
||||
}
|
||||
|
||||
function directionalLanes(osmWayId, roadId, coordinates, direction, widthMeters, offsets, firstIndex) {
|
||||
const oriented = direction === "Fwd" ? coordinates : [...coordinates].reverse();
|
||||
return offsets.map((offsetMeters, index) => lanePolygon(
|
||||
osmWayId, roadId, oriented, direction, firstIndex + index, widthMeters, offsetMeters,
|
||||
));
|
||||
}
|
||||
|
||||
function lanePolygon(osmWayId, roadId, coordinates, direction, index, widthMeters, offsetMeters) {
|
||||
const centerline = offsetLineRight(coordinates, offsetMeters);
|
||||
const left = offsetLineRight(centerline, -widthMeters / 2);
|
||||
const right = offsetLineRight(centerline, widthMeters / 2);
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
type: "Driving",
|
||||
direction,
|
||||
index,
|
||||
width: widthMeters,
|
||||
road: roadId,
|
||||
osm_way_ids: [osmWayId],
|
||||
allowed_turns: [],
|
||||
},
|
||||
geometry: { type: "Polygon", coordinates: [[...left, ...right.reverse(), left[0]]] },
|
||||
};
|
||||
}
|
||||
|
||||
function laneSpec(direction, widthMeters) {
|
||||
return { lt: "Driving", dir: direction, width: widthMeters * 10000, allowed_turns: 0 };
|
||||
}
|
||||
|
||||
function networkRoad(id, osmWayId, src, dst, coordinates, laneSpecs, bounds, highwayType = "residential") {
|
||||
return {
|
||||
id,
|
||||
osm_ids: [osmWayId],
|
||||
src_i: src,
|
||||
dst_i: dst,
|
||||
highway_type: highwayType,
|
||||
name: osmWayId,
|
||||
center_line: { pts: coordinates.map((coordinate) => networkPoint(coordinate, bounds)) },
|
||||
lane_specs_ltr: laneSpecs,
|
||||
};
|
||||
}
|
||||
|
||||
function networkPoint([lon, lat], bounds) {
|
||||
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]);
|
||||
return {
|
||||
x: Math.round((lon - bounds.min_lon) / (bounds.max_lon - bounds.min_lon) * widthMeters * 10000),
|
||||
y: Math.round((heightMeters - (lat - bounds.min_lat) / (bounds.max_lat - bounds.min_lat) * heightMeters) * 10000),
|
||||
};
|
||||
}
|
||||
|
||||
function intersectionFeature(id, coordinate, halfSizeMeters) {
|
||||
const west = metersCoordinate(coordinate, -halfSizeMeters, 0)[0];
|
||||
const east = metersCoordinate(coordinate, halfSizeMeters, 0)[0];
|
||||
const south = metersCoordinate(coordinate, 0, -halfSizeMeters)[1];
|
||||
const north = metersCoordinate(coordinate, 0, halfSizeMeters)[1];
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: { id, type: "intersection" },
|
||||
geometry: { type: "Polygon", coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] },
|
||||
};
|
||||
}
|
||||
|
||||
function offsetLineRight(coordinates, offsetMeters) {
|
||||
const [start, end] = coordinates;
|
||||
const latitude = (start[1] + end[1]) / 2;
|
||||
const metersLon = 111320 * Math.cos(latitude * Math.PI / 180);
|
||||
const dx = (end[0] - start[0]) * metersLon;
|
||||
const dy = (end[1] - start[1]) * 111320;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const east = dy / length * offsetMeters;
|
||||
const north = -dx / length * offsetMeters;
|
||||
return coordinates.map(([lon, lat]) => [lon + east / metersLon, lat + north / 111320]);
|
||||
}
|
||||
|
||||
function metersCoordinate(origin, east, north) {
|
||||
const metersLon = 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||
return [origin[0] + east / metersLon, origin[1] + north / 111320];
|
||||
}
|
||||
|
||||
function eastMeters(origin, coordinate) {
|
||||
return (coordinate[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||
}
|
||||
|
||||
function tangentMismatchDegrees(a, b, c, d) {
|
||||
const metersLon = 111320 * Math.cos((b[1] + c[1]) / 2 * Math.PI / 180);
|
||||
const first = [(b[0] - a[0]) * metersLon, (b[1] - a[1]) * 111320];
|
||||
const second = [(d[0] - c[0]) * metersLon, (d[1] - c[1]) * 111320];
|
||||
const cosine = (first[0] * second[0] + first[1] * second[1]) / (Math.hypot(...first) * Math.hypot(...second));
|
||||
return Math.acos(Math.max(-1, Math.min(1, cosine))) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
function maxStepMeters(coordinates) {
|
||||
return Math.max(...coordinates.slice(1).map((coordinate, index) => haversineMeters(coordinates[index], coordinate)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user