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

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