fix: 修正 Cesium 巡航车道中心对齐

This commit is contained in:
2026-08-08 15:21:49 +08:00
parent aeb2cec021
commit 5658e7337d
17 changed files with 1081 additions and 192 deletions

View File

@@ -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}`);

View File

@@ -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)

View File

@@ -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"),
},

View File

@@ -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) {

View 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,
};

View File

@@ -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);
}

View File

@@ -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,
};

View File

@@ -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.");

View File

@@ -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)));
}