467 lines
26 KiB
JavaScript
467 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const assert = require("assert");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
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,
|
|
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">
|
|
<bounds minlon="120" minlat="30" maxlon="120.01" maxlat="30.01"/>
|
|
<node id="1" lon="120.001" lat="30.001"/>
|
|
<node id="2" lon="120.003" lat="30.001"/>
|
|
<node id="3" lon="120.005" lat="30.002"/>
|
|
<node id="4" lon="120.007" lat="30.002"/>
|
|
<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="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="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"/>
|
|
</way>
|
|
<way id="oneway-spur">
|
|
<nd ref="4"/><nd ref="5"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/>
|
|
</way>
|
|
<way id="footpath">
|
|
<nd ref="1"/><nd ref="5"/><tag k="highway" v="footway"/>
|
|
</way>
|
|
</osm>
|
|
`);
|
|
|
|
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,
|
|
maxLon: 120.01,
|
|
maxLat: 30.01,
|
|
});
|
|
assert.equal(route.speedMetersPerSecond, 8);
|
|
assert.equal(route.loop, true);
|
|
assert.equal(route.segments, route.routes, "legacy segments remains an alias for the new route list");
|
|
assert.ok(route.routes.length >= 1 && route.routes.length <= 5);
|
|
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("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 = { 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");
|
|
assert.equal(vehicle.asset.generator, "osm-asset-pipeline vehicle preview");
|
|
assert.equal(vehicle.scene, 0);
|
|
assert.equal(vehicle.meshes.length, vehicle.nodes.length);
|
|
assert.equal(vehicle.scenes[0].nodes.length, vehicle.nodes.length);
|
|
assert.deepEqual(
|
|
vehicle.materials.map((material) => material.name),
|
|
["paint red", "dark roof", "glass", "tire", "wheel hub", "headlight", "tail light"],
|
|
);
|
|
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "body"));
|
|
assert.ok(vehicle.meshes.some((mesh) => mesh.name === "wheel_-1.55_-1.02"));
|
|
assert.match(vehicle.buffers[0].uri, /^data:application\/octet-stream;base64,/);
|
|
assert.equal(
|
|
Buffer.from(vehicle.buffers[0].uri.split(",")[1], "base64").length,
|
|
vehicle.buffers[0].byteLength,
|
|
);
|
|
|
|
const vehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "custom", "lowpoly_cars");
|
|
if (fs.existsSync(vehicleLibraryRoot)) {
|
|
const libraryDir = path.join(tempDir, "vehicle-library");
|
|
const models = writePreviewVehicleLibrary(libraryDir, "fixture");
|
|
assert.deepEqual(models, VEHICLE_IDS.map((id) => `fixture-vehicle-${id}.gltf`));
|
|
assert.equal(models.length, 6);
|
|
assert.ok(fs.existsSync(path.join(libraryDir, "fixture-vehicle-texture.jpg")));
|
|
for (const model of models) {
|
|
const gltf = JSON.parse(fs.readFileSync(path.join(libraryDir, model), "utf8"));
|
|
assert.deepEqual(gltf.images.map((image) => image.uri), ["fixture-vehicle-texture.jpg"]);
|
|
assert.equal(gltf.buffers[0].uri, model.replace(/\.gltf$/, ".bin"));
|
|
assert.deepEqual(gltf.materials[0].pbrMetallicRoughness.baseColorFactor, [2.1, 2.1, 2.1, 1]);
|
|
assert.deepEqual(gltf.materials[0].emissiveFactor, [0.25, 0.25, 0.25]);
|
|
assert.equal(gltf.materials[0].emissiveTexture.index, gltf.materials[0].pbrMetallicRoughness.baseColorTexture.index);
|
|
const reversed = model.includes("truck_a03_001");
|
|
assert.equal(reversed, REVERSED_MODEL_IDS.has(model.match(/vehicle-(.+)\.gltf$/)[1]));
|
|
assert.equal(gltf.nodes.every((node) => JSON.stringify(node.rotation || []) === JSON.stringify([0, 1, 0, 0])), reversed);
|
|
}
|
|
}
|
|
|
|
const html = cesiumPreviewHtml(
|
|
"scene<&>.glb",
|
|
"scene.json",
|
|
"route.json",
|
|
"vehicle.gltf",
|
|
"north<&>\u2028valley",
|
|
["car-a.gltf", "truck-a.gltf"],
|
|
"traffic-signals.json",
|
|
null,
|
|
{ enabled: true, apiBaseUrl: "/api", wsBaseUrl: "/websocket", crossCode: "420100023333" },
|
|
);
|
|
assert.match(html, /<title>north<&>\u2028valley Cesium Preview<\/title>/);
|
|
assert.match(html, /Loading scene<&>\.glb/);
|
|
assert.match(html, /"areaId":"north\\u003c\\u0026\\u003e\\u2028valley"/);
|
|
assert.match(html, /"glbName":"scene\\u003c\\u0026\\u003e\.glb"/);
|
|
assert.match(html, /"vehicleModelNames":\["car-a\.gltf","truck-a\.gltf"\]/);
|
|
assert.match(html, /"trafficSignalsName":"traffic-signals\.json"/);
|
|
assert.match(html, /"v2xPreview":\{"enabled":true,"apiBaseUrl":"\/api","wsBaseUrl":"\/websocket","crossCode":"420100023333"\}/);
|
|
assert.match(html, /<script src="v2x-cesium-overlay\.js"><\/script>/);
|
|
assert.match(html, /id="toggleSignals"/);
|
|
assert.match(html, /id="vehicleInfoCard" class="hidden"/);
|
|
assert.match(html, /id="vehicleIncidentNote"/);
|
|
assert.match(html, /data-vehicle-status="breakdown"/);
|
|
assert.match(html, /data-vehicle-status="accident"/);
|
|
assert.match(html, /id="toggleBuildingGhost"/);
|
|
assert.match(html, /id="viewMode"/);
|
|
assert.match(html, /data-view-mode="inspect"/);
|
|
assert.match(html, /id="semanticToggles" class="control-subgroup hidden"/);
|
|
|
|
const previewRuntime = fs.readFileSync(path.join(__dirname, "lib", "cesium-preview.js"), "utf8");
|
|
const v2xRuntime = fs.readFileSync(path.join(__dirname, "lib", "v2x-cesium-overlay.js"), "utf8");
|
|
const buildAreaSource = fs.readFileSync(path.join(__dirname, "build-area.js"), "utf8");
|
|
assert.match(buildAreaSource, /const vehicleModelNames = \[\];/);
|
|
assert.doesNotMatch(buildAreaSource, /buildNativeTrafficSimulation/);
|
|
const countdownFont = path.join(__dirname, "..", "assets", "fonts", "7LED-1.ttf");
|
|
assert.ok(fs.existsSync(countdownFont), "7LED countdown font must be versioned with the project");
|
|
assert.doesNotMatch(previewRuntime, /cylinder: \{ length: 6\.7/);
|
|
assert.doesNotMatch(previewRuntime, /Traffic Signal Housing/);
|
|
assert.match(previewRuntime, /asset\.category === "dynamic"/);
|
|
assert.match(previewRuntime, /createV2xCesiumOverlay/);
|
|
assert.match(v2xRuntime, /\/facilities\/api\/sys\/login/);
|
|
assert.match(v2xRuntime, /sessionStorage/);
|
|
assert.match(v2xRuntime, /GCJ-02 -> WGS84 once/);
|
|
assert.match(v2xRuntime, /\/network\/ws\/network\/signal/);
|
|
assert.match(v2xRuntime, /\/network\/ws\/network\/obuPosition/);
|
|
assert.match(v2xRuntime, /\/network\/ws\/network\/targetPosition/);
|
|
assert.doesNotMatch(v2xRuntime, /Intersection code<input/);
|
|
assert.match(v2xRuntime, /V2X intersection code is not configured/);
|
|
assert.match(v2xRuntime, /gcj02ToWgs84/);
|
|
assert.match(previewRuntime, /createLiveVehicleState\(\)/);
|
|
assert.doesNotMatch(previewRuntime, /const cruise = addVehicleCruises\(/);
|
|
assert.match(previewRuntime, /new Cesium\.ScreenSpaceEventHandler/);
|
|
assert.match(previewRuntime, /vehicleId: record\.id/);
|
|
assert.match(previewRuntime, /status === "breakdown"\s+\? "vehicle-breakdown\.png"/);
|
|
assert.match(previewRuntime, /accident \? "vehicle-accident\.png"/);
|
|
assert.match(previewRuntime, /disableDepthTestDistance: Number\.POSITIVE_INFINITY/);
|
|
assert.match(previewRuntime, /routeEntity\.polyline\.material = accident \? Cesium\.Color\.RED : vehicle\.routeColor/);
|
|
assert.match(previewRuntime, /\(\) => record\?\.status === "normal"/);
|
|
assert.match(previewRuntime, /Cesium\.SceneTransforms\.worldToWindowCoordinates/);
|
|
assert.match(previewRuntime, /TrafficSignalDynamic_/);
|
|
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
|
|
assert.doesNotMatch(previewRuntime, /native-preview-traffic-simulation\/v1/);
|
|
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/);
|
|
assert.doesNotMatch(previewRuntime, /digitMap/);
|
|
assert.match(previewRuntime, /Do not cache a miss/);
|
|
assert.match(previewRuntime, /scene\.requestRender/);
|
|
assert.doesNotMatch(previewRuntime, /function addTrafficSignals\(viewer, signalData, start, placement\)/);
|
|
assert.doesNotMatch(previewRuntime, /ellipsoid:/);
|
|
|
|
const trafficIntersection = { type: "FeatureCollection", features: [
|
|
{ type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
|
[119.9998, 29.9998], [120.0004, 29.9998], [120.0004, 30.0003], [119.9998, 30.0003], [119.9998, 29.9998],
|
|
]] } },
|
|
] };
|
|
const tStopLines = { type: "FeatureCollection", features: [
|
|
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
|
|
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
|
|
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
|
|
] };
|
|
const control = {
|
|
id: "traffic-t", longitude: 120.0001, latitude: 30.00005,
|
|
arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }],
|
|
};
|
|
const noControlSignals = buildTrafficSignals(tStopLines, trafficIntersection);
|
|
assert.equal(noControlSignals.signals.length, 0, "untagged intersections must not create traffic signals");
|
|
|
|
const tSignals = buildTrafficSignals(tStopLines, trafficIntersection, [control]);
|
|
assert.equal(tSignals.version, 3);
|
|
assert.equal(tSignals.signals.length, 3, "a tagged T junction has one signal per physical approach");
|
|
assert.deepEqual(tSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1]);
|
|
assert.ok(tSignals.signals.every((signal) => Number.isFinite(signal.headingDegrees)));
|
|
assert.equal(tSignals.layout.countdownLateralMeters, 1.15);
|
|
assert.equal(tSignals.layout.countdownWidthMeters, 0.82);
|
|
assert.ok(tSignals.signals.every((signal) => signal.pose?.head && signal.pose.lenses.length === 3));
|
|
|
|
const crossSignals = buildTrafficSignals(
|
|
{ type: "FeatureCollection", features: [
|
|
rectangle(119.99995, 30.00005, 0.00003, 0.000006),
|
|
rectangle(120.00010, 30.00025, 0.00003, 0.000006),
|
|
rectangle(120.00035, 30.00005, 0.00003, 0.000006),
|
|
rectangle(120.00010, 29.99985, 0.00003, 0.000006),
|
|
] },
|
|
trafficIntersection,
|
|
[{ ...control, arms: [{ headingDegrees: 270 }, { headingDegrees: 0 }, { headingDegrees: 90 }, { headingDegrees: 180 }] }],
|
|
);
|
|
assert.equal(crossSignals.signals.length, 4, "a tagged cross junction retains all four approaches");
|
|
assert.deepEqual(crossSignals.signals.map((signal) => signal.phaseGroup).sort(), [0, 0, 1, 1]);
|
|
|
|
const parsedSignalControls = parseOsm(`
|
|
<osm><bounds minlon="119" minlat="29" maxlon="121" maxlat="31" />
|
|
<node id="active" lon="120" lat="30"><tag k="highway" v="traffic_signals" /><tag k="traffic_signals:direction" v="both" /></node>
|
|
<node id="directionless" lon="120" lat="30"><tag k="highway" v="traffic_signals" /></node>
|
|
<node id="deleted" lon="120" lat="30" action="delete"><tag k="highway" v="traffic_signals" /></node>
|
|
<node id="crossing" lon="120" lat="30"><tag k="highway" v="crossing" /><tag k="crossing" v="traffic_signals" /></node>
|
|
</osm>`).trafficSignalControls;
|
|
assert.deepEqual(parsedSignalControls.map((entry) => entry.id), ["active", "directionless"], "only active highway=traffic_signals nodes control vehicle signals");
|
|
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
console.log("Preview asset tests passed.");
|
|
|
|
function rectangle(lon, lat, halfWidth, halfHeight) {
|
|
return { type: "Feature", geometry: { type: "Polygon", coordinates: [[
|
|
[lon - halfWidth, lat - halfHeight], [lon + halfWidth, lat - 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)));
|
|
}
|