fix: 修正 Cesium 巡航车道中心对齐
This commit is contained in:
@@ -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