#!/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, V2X_VEHICLE_MODEL_NAMES, writePreviewVehicleLibrary, writePreviewV2xVehicleLibrary, } = require("./lib/vehicle-library"); const { allowedTurns, buildVehicleRoute, classifyConnection, tangentBezierTurn, uTurnConnector, } = require("./lib/vehicle-route"); const { buildTrafficSignals } = require("./lib/traffic-signals"); const { laneGeometry: { haversineMeters, laneCenterline }, osm: { parseOsm } } = require("@osm-asset/road-compiler"); 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 v2xVehicleLibraryRoot = path.join(__dirname, "..", "assets", "models", "v2x-vehicles"); if (fs.existsSync(v2xVehicleLibraryRoot)) { const v2xLibraryDir = path.join(tempDir, "v2x-vehicle-library"); const models = writePreviewV2xVehicleLibrary(v2xLibraryDir); assert.deepEqual(models, V2X_VEHICLE_MODEL_NAMES.map((name) => `v2x-vehicles/${name}`)); for (const model of models) assert.ok(fs.existsSync(path.join(v2xLibraryDir, model))); } 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, /<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 = writeVehicleModel\(area\);/); 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.match(v2xRuntime, /headingPitchRollQuaternion/); assert.match(v2xRuntime, /model:/); assert.match(previewRuntime, /createLiveTrafficSignals\(/); assert.doesNotMatch(previewRuntime, /const trafficSignals = addTrafficSignals\(/); // The lamp dictionary lives only in the overlay, which hands the preview // already-normalized {nodeKeys, color, countDown} entries. Two copies of the // dictionary drifted apart once and made every phase render red. assert.doesNotMatch(previewRuntime, /function lampColorName/, "the preview must not keep its own lamp status dictionary"); assert.doesNotMatch(previewRuntime, /Number\(status\) === 3/, "status 3 is not green; the real codes are 21/22/23"); assert.match(previewRuntime, /entry\.nodeKeys/, "live signals are addressed by resolved native node keys"); assert.match(previewRuntime, /paintCountdown\(/, "countDown from the push drives the countdown assets"); assert.match(previewRuntime, /nativeSignals: \(signalData && signalData\.signals\) \|\| \[\]/, "the overlay needs the native signal list to bind phases"); assert.match(previewRuntime, /registry\?\.visibleSize/, "diagnostics must report the visible live-vehicle count"); assert.match(previewRuntime, /Target WS:/, "diagnostics must expose target websocket receipt and parsing counts"); assert.match(previewRuntime, /Target payload:/, "diagnostics must expose the payload shape when target pushes have no vehicles"); assert.match(v2xRuntime, /minimumPixelSize: 28/, "live vehicles remain visible from the intersection overview"); assert.match(v2xRuntime, /LIVE_VEHICLE_HEIGHT_METERS = 1\.2/, "live vehicles stay clear of the static road surface"); assert.match(v2xRuntime, /LIVE_VEHICLE_HEADING_OFFSET_DEGREES = 90/, "live dashboard vehicle models receive their required clockwise heading correction"); // Overlay-side regressions: lamp codes, heartbeat, subscription frames, // vehicle lifecycle and the removed out-of-scope request. assert.match(v2xRuntime, /LAMP_STATUS = \{ 11: "off", 21: "red", 22: "yellow", 23: "green", 31: "other" \}/); assert.doesNotMatch(v2xRuntime, /Number\(status\) === 3/); assert.match(v2xRuntime, /heartBeat: "ping"/, "all sockets must heartbeat like the dashboard"); assert.match(v2xRuntime, /HEARTBEAT_INTERVAL_MS = 30000/); assert.match(v2xRuntime, /scheduleReconnect/, "a dropped socket must reconnect"); assert.match(v2xRuntime, /buildBoundsMessage/, "the OBU socket must send a bounds frame"); assert.match(v2xRuntime, /createVehicleRegistry/, "stale vehicles must be swept"); assert.match(v2xRuntime, /linkEntitiesByPhase/, "one phase may light several approaches"); assert.doesNotMatch(v2xRuntime, /linkPhases/, "the phase -> single entity map overwrote approaches"); assert.doesNotMatch(v2xRuntime, /FlowTravelRatio/, "weekly flow ratio is not part of the live intersection view"); assert.doesNotMatch(v2xRuntime, /Intersection code<input/); assert.match(v2xRuntime, /Finding the configured V2X intersection/); assert.match(v2xRuntime, /crossDeviceConfig\/queryList/); 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))); }