"use strict"; const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); const SCHEMA = "native-preview-traffic-simulation/v1"; const MAX_ROUTES = 5; const MIN_ROAD_COUNT = 3; const MAX_ROAD_COUNT = 7; const MAX_CONNECTION_GAP_METERS = 35; const DEFAULT_SETTINGS = { desiredSpeedMetersPerSecond: 8, accelerationMetersPerSecondSquared: 1.8, decelerationMetersPerSecondSquared: 3.5, reactionTimeSeconds: 0.8, vehicleLengthMeters: 4.6, minimumGapMeters: 7, }; function buildNativeTrafficSimulation(area, options = {}) { const root = options.nativePackage?.rootDir; if (!root) throw new Error("Native traffic simulation requires an imported native road package"); const compiledPath = path.join(root, "compiled.json"); const signalPath = path.join(root, "traffic-signals.json"); const connectorPath = path.join(root, "layers", "connectors.geojson"); const laneCenterlinePath = path.join(root, "layers", "lane_centerlines.geojson"); const stopLinePath = path.join(root, "layers", "vehicle_stop_lines.geojson"); for (const file of [compiledPath, signalPath, connectorPath, stopLinePath]) { if (!fs.existsSync(file)) throw new Error(`Native traffic simulation input not found: ${file}`); } const compiled = readJson(compiledPath, "native compiled roads"); const signals = readJson(signalPath, "native traffic signals"); const connectors = readFeatureCollection(connectorPath, "native connectors"); const laneCenterlines = fs.existsSync(laneCenterlinePath) ? readFeatureCollection(laneCenterlinePath, "native lane centerlines") : null; const stopLines = readFeatureCollection(stopLinePath, "native stop lines"); const settings = { ...DEFAULT_SETTINGS, ...(options.settings || {}) }; validateSettings(settings); const routeBuild = buildRoutes(compiled, connectors, signals.signals || [], stopLines.features, laneCenterlines); return { schema: SCHEMA, areaId: area.id, coordinateSystem: { route: "WGS84", model: "ENU", units: "meters", axes: "X east / Y north / Z up" }, generatedAt: new Date().toISOString(), speedMetersPerSecond: settings.desiredSpeedMetersPerSecond, source: sourceRecords(area, [compiledPath, signalPath, connectorPath, stopLinePath, ...(laneCenterlines ? [laneCenterlinePath] : [])]), settings, routes: routeBuild.routes, signals: (signals.signals || []).filter((signal) => signal && signal.id).map(signalDescriptor), diagnostics: [ ...routeBuild.diagnostics, ...(routeBuild.routes.length ? [] : [{ severity: "warning", reason: "no_native_cruise_route", message: "No deterministic native route crossed three or more directional roads." }]), ], rejectedRoutes: routeBuild.rejectedRoutes, migration: { schema: SCHEMA, updateLoop: "advance route distance using settings, stop before red/yellow signal, then apply leader minimum gap", signalIdentity: "signals[].id is the existing traffic runtime signal_uid", coordinates: "routes[].coordinates are WGS84; static package remains local ENU placed by package manifest", legacyDependency: false, }, }; } function buildRoutes(compiled, connectorCollection, signals, stopLineFeatures, laneCenterlineCollection) { const roads = new Map((compiled.model?.roads || []).map((road) => [road.id, road])); const movements = (compiled.movements || []).filter((movement) => movement.geometryPublished !== false && roads.has(movement.fromRoadId) && roads.has(movement.toRoadId)); const connectorByMovement = new Map(connectorCollection.features.map((feature) => [feature.properties?.movement_id, feature.geometry?.coordinates || []])); const laneCenterlineById = new Map(); for (const feature of laneCenterlineCollection?.features || []) { const properties = feature.properties || {}; const coordinates = feature.geometry?.coordinates || []; if (properties.native_id) laneCenterlineById.set(properties.native_id, coordinates); if (properties.road_id && Number.isFinite(Number(properties.lane_index))) laneCenterlineById.set(`${properties.road_id}:${properties.lane_index}`, coordinates); } const outgoing = new Map(); for (const movement of movements) { if (!outgoing.has(movement.fromRoadId)) outgoing.set(movement.fromRoadId, []); outgoing.get(movement.fromRoadId).push(movement); } for (const list of outgoing.values()) list.sort((a, b) => a.id.localeCompare(b.id)); const candidates = []; const seen = new Set(); for (const start of [...roads.keys()].sort()) { findCycles(start, start, [], [], outgoing, seen, candidates); if (candidates.length >= MAX_ROUTES * 3) break; } const diagnostics = []; const rejectedRoutes = []; const routes = candidates .map((candidate) => { const result = makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById); if (!result.route) { rejectedRoutes.push({ id: `native-loop:${candidate.roadIds.join("-")}`, diagnostics: result.diagnostics }); diagnostics.push(...result.diagnostics); } return result.route; }) .filter(Boolean) .sort((a, b) => a.id.localeCompare(b.id)) .slice(0, MAX_ROUTES); return { routes, diagnostics, rejectedRoutes }; } function findCycles(start, current, roadIds, movements, outgoing, seen, candidates) { const nextRoadIds = [...roadIds, current]; if (nextRoadIds.length > MAX_ROAD_COUNT) return; for (const movement of outgoing.get(current) || []) { if (movement.toRoadId === start && nextRoadIds.length >= MIN_ROAD_COUNT) { if (!laneSequenceCompatible(movements.at(-1), movement) || !laneSequenceCompatible(movement, movements[0])) continue; const signature = [...nextRoadIds, start].join("|"); if (!seen.has(signature)) { seen.add(signature); candidates.push({ roadIds: nextRoadIds, movements: [...movements, movement] }); } continue; } if (nextRoadIds.includes(movement.toRoadId)) continue; if (!laneSequenceCompatible(movements.at(-1), movement)) continue; findCycles(start, movement.toRoadId, nextRoadIds, [...movements, movement], outgoing, seen, candidates); } } function laneSequenceCompatible(previous, next) { if (!previous || !previous.toLaneId || !next?.fromLaneId) return true; return previous.toLaneId === next.fromLaneId; } function makeRoute(candidate, roads, connectorByMovement, signals, stopLineFeatures, laneCenterlineById) { const coordinates = []; const connectors = []; const diagnostics = []; for (let index = 0; index < candidate.roadIds.length; index += 1) { const road = roads.get(candidate.roadIds[index]); const incomingMovement = candidate.movements[(index - 1 + candidate.movements.length) % candidate.movements.length]; const incomingLine = selectLaneCenterline(laneCenterlineById, road, incomingMovement?.toLaneId); const roadLine = incomingLine || road.centerline; append(coordinates, roadLine); const movement = candidate.movements[index]; const connector = connectorByMovement.get(movement.id) || []; if (connector.length < 2) { diagnostics.push(routeDiagnostic(candidate, movement, "missing_connector_geometry", "Native movement has no usable connector geometry.")); return { route: null, diagnostics }; } const startGap = distanceMeters(coordinates.at(-1), connector[0]); if (startGap > MAX_CONNECTION_GAP_METERS) { diagnostics.push(routeDiagnostic(candidate, movement, "road_connector_gap", `Road to connector endpoint gap is ${round(startGap)}m.`)); return { route: null, diagnostics }; } append(coordinates, connector); if (index < candidate.roadIds.length - 1) { const nextRoad = roads.get(candidate.roadIds[index + 1]); const nextLine = selectLaneCenterline(laneCenterlineById, nextRoad, movement.toLaneId) || nextRoad.centerline; const endGap = distanceMeters(connector.at(-1), nextLine[0]); if (endGap > MAX_CONNECTION_GAP_METERS) { diagnostics.push(routeDiagnostic(candidate, movement, "connector_road_gap", `Connector to road endpoint gap is ${round(endGap)}m.`)); return { route: null, diagnostics }; } } connectors.push({ id: movement.connectorId, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId, turn: movement.turn }); } if (coordinates.length < 4) return { route: null, diagnostics }; const distances = cumulativeDistances(coordinates); const lengthMeters = distances.at(-1); if (!(lengthMeters > 30)) return { route: null, diagnostics }; const stops = matchStops(coordinates, distances, signals, stopLineFeatures); return { route: { id: `native-loop:${candidate.roadIds.join("-")}`, coordinates, centerlineCoordinates: coordinates, distances, lengthMeters: round(lengthMeters), edgeIds: candidate.roadIds, laneSegments: candidate.roadIds.map((roadId) => ({ roadId, laneId: `lane:${roadId}:1` })), connectors, maneuvers: connectors.map((connector) => connector.turn).filter(Boolean), stops, }, diagnostics }; } function selectLaneCenterline(laneCenterlineById, road, laneId) { if (!laneCenterlineById || !road) return null; if (laneId && laneCenterlineById.has(laneId)) return laneCenterlineById.get(laneId); const fallback = laneCenterlineById.get(`${road.id}:1`); return fallback || null; } function routeDiagnostic(candidate, movement, reason, message) { return { severity: "warning", reason, message, routeRoadIds: candidate.roadIds, movementId: movement.id, fromRoadId: movement.fromRoadId, toRoadId: movement.toRoadId }; } function matchStops(coordinates, distances, signals, stopLineFeatures) { const stopRoadIds = new Set(stopLineFeatures.map((feature) => feature.properties?.road_id).filter(Boolean)); return signals.map((signal) => { if (!Number.isFinite(signal.stopLongitude) || !Number.isFinite(signal.stopLatitude)) return null; let best = { distance: Infinity, routeDistance: 0 }; for (let index = 0; index < coordinates.length; index += 1) { const distance = haversine(coordinates[index], [signal.stopLongitude, signal.stopLatitude]); if (distance < best.distance) best = { distance, routeDistance: distances[index] }; } if (best.distance > 15 || !stopRoadIds.size) return null; return { signalId: signal.id, phaseGroup: signal.phaseGroup, distance: round(best.routeDistance), matchDistanceMeters: round(best.distance), stopReason: "traffic-signal" }; }).filter(Boolean).sort((a, b) => a.distance - b.distance); } function signalDescriptor(signal) { return { id: signal.id, phaseGroup: signal.phaseGroup, approachId: signal.approachId, sourceWayId: signal.sourceWayId, stopLongitude: signal.stopLongitude, stopLatitude: signal.stopLatitude, enabled: true }; } function sourceRecords(area, files) { return files.map((file) => ({ path: path.relative(area.outputs.areaDir, file).split(path.sep).join("/"), bytes: fs.statSync(file).size, sha256: sha256(file) })); } function append(target, points) { for (const point of points || []) { const coordinate = point.slice(0, 2).map(Number); if (!coordinate.every(Number.isFinite)) continue; const previous = target.at(-1); if (!previous || previous[0] !== coordinate[0] || previous[1] !== coordinate[1]) target.push(coordinate); } } function cumulativeDistances(coordinates) { const distances = [0]; for (let index = 1; index < coordinates.length; index += 1) distances.push(distances[index - 1] + haversine(coordinates[index - 1], coordinates[index])); return distances; } function haversine(a, b) { const radians = Math.PI / 180; const dLat = (b[1] - a[1]) * radians; const dLon = (b[0] - a[0]) * radians; const lat1 = a[1] * radians; const lat2 = b[1] * radians; const value = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; return 6371008.8 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value)); } function distanceMeters(a, b) { return a && b ? haversine(a, b) : Infinity; } function readJson(file, label) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (error) { throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`); } } function readFeatureCollection(file, label) { const value = readJson(file, label); if (value?.type !== "FeatureCollection" || !Array.isArray(value.features)) throw new Error(`Invalid ${label} '${file}': expected FeatureCollection`); return value; } function validateSettings(settings) { for (const [key, value] of Object.entries(settings)) if (!Number.isFinite(value) || value <= 0) throw new Error(`Native traffic simulation setting '${key}' must be positive`); } function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } function round(value) { return Math.round(value * 100) / 100; } module.exports = { SCHEMA, DEFAULT_SETTINGS, buildNativeTrafficSimulation };