#!/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, `
`);
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, /
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, /