feat: add native preview traffic simulation

This commit is contained in:
2026-08-18 16:57:27 +08:00
parent 0bc949bc24
commit 5403936ae4
21 changed files with 777 additions and 45 deletions

View File

@@ -66,6 +66,7 @@ function normalizeAreaConfig(raw, options = {}) {
),
previewDir,
previewDescriptor: path.resolve(outputOverrides.previewDescriptor || path.join(previewDir, "descriptor.json")),
trafficSimulation: path.resolve(outputOverrides.trafficSimulation || path.join(previewDir, `${fileStem}-traffic-simulation.json`)),
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(previewDir, `${fileStem}-vehicle-route.json`)),
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(previewDir, `${fileStem}-vehicle-car.gltf`)),
trafficSignalAssemblies: path.resolve(

View File

@@ -473,15 +473,18 @@ function stageManifestStatus(area, configPath = null) {
network: path.join(area.outputs.geojsonDir, "network.json"),
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
}),
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
},
outputs: compressionComplete ? {
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
vehicleModel: area.outputs.vehicleModel,
} : {
cesiumPreview: area.outputs.cesiumPreview,
vehicleRoute: native ? optionalExpectedFile(area.outputs.vehicleRoute) : area.outputs.vehicleRoute,
...(native ? { trafficSimulation: area.outputs.trafficSimulation } : {}),
vehicleModel: area.outputs.vehicleModel,
},
},

View File

@@ -123,15 +123,15 @@ function escapeScriptJson(value) {
.replaceAll("\u2029", "\\u2029");
}
function previewSummary(area) {
const route = fs.existsSync(area.outputs.vehicleRoute) ? JSON.parse(fs.readFileSync(area.outputs.vehicleRoute, "utf8")) : null;
function previewSummary(area, routePath = area.outputs.vehicleRoute) {
const route = routePath && fs.existsSync(routePath) ? JSON.parse(fs.readFileSync(routePath, "utf8")) : null;
return {
glbName: "package/manifest.json",
metadataName: "package/manifest.json",
routeName: route ? path.relative(area.outputs.areaDir, area.outputs.vehicleRoute).split(path.sep).join("/") : null,
routeName: route ? path.relative(area.outputs.areaDir, routePath).split(path.sep).join("/") : null,
vehicleModelName: path.relative(area.outputs.areaDir, area.outputs.vehicleModel).split(path.sep).join("/"),
trafficSignalsName: "package/runtime/traffic-signals.json",
routeSegments: Array.isArray(route?.segments) ? route.segments.length : null,
routeSegments: Array.isArray(route?.routes) ? route.routes.length : Array.isArray(route?.segments) ? route.segments.length : null,
};
}

View File

@@ -555,16 +555,27 @@
.slice(0, 5);
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
const start = trafficStart;
const assignments = [];
segments.forEach((segment, routeIndex) => {
// Put two vehicles on the first route so the native preview visibly
// exercises leader following and queue formation.
const vehicleCount = routeIndex === 0 ? 2 : 1;
for (let vehicleIndex = 0; vehicleIndex < vehicleCount; vehicleIndex += 1) {
assignments.push({ segment, routeIndex, vehicleIndex });
}
});
const simulation = createTrafficSimulation(viewer, assignments, start, speed, signalData, routeData?.settings);
viewer.clock.startTime = start.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.UNBOUNDED;
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = segments.length > 0;
viewer.clock.shouldAnimate = assignments.length > 0;
const vehicles = segments.map((segment, index) => {
const vehicles = assignments.map((assignment, index) => {
const { segment } = assignment;
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, signalData,
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName));
selectedVehicleModelName(vehicleModelNames, fallbackVehicleModelName), simulation.motions[index]);
const option = document.createElement("option");
option.value = String(index);
option.textContent = routeLabel(segment, index);
@@ -574,7 +585,8 @@
const cruise = {
vehicles,
baseSpeed: speed,
state: { selectedIndex: 0 }
state: { selectedIndex: 0 },
simulation,
};
syncSelectedRouteVisibility(cruise);
return cruise;
@@ -732,10 +744,10 @@
return usable[Math.floor(Math.random() * usable.length)] || "";
}
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName) {
const route = prepareRoute(segment, signalData);
function addCruiseVehicle(viewer, segment, index, start, speed, signalData, vehicleModelName, trafficMotion) {
const route = trafficMotion.route;
let record = null;
const trafficMotion = createTrafficAwarePositions(viewer, route, start, speed, () => record?.status === "normal");
trafficMotion.setMoving(() => record?.status === "normal");
const positions = trafficMotion.positions;
const flat = [];
for (const coord of segment.coordinates) {
@@ -946,6 +958,106 @@
return [...found.values()].sort((a, b) => a.distance - b.distance);
}
function createTrafficSimulation(viewer, assignments, start, speed, signalData, settings = {}) {
const config = {
desiredSpeedMetersPerSecond: Number(settings.desiredSpeedMetersPerSecond || speed),
accelerationMetersPerSecondSquared: Number(settings.accelerationMetersPerSecondSquared || 1.8),
decelerationMetersPerSecondSquared: Number(settings.decelerationMetersPerSecondSquared || 3.5),
reactionTimeSeconds: Number(settings.reactionTimeSeconds || 0.8),
vehicleLengthMeters: Number(settings.vehicleLengthMeters || 4.6),
minimumGapMeters: Number(settings.minimumGapMeters || 7),
};
const motions = assignments.map((assignment) => {
const route = prepareRoute(assignment.segment, signalData);
const initialGap = config.vehicleLengthMeters + config.minimumGapMeters;
const state = {
distance: assignment.vehicleIndex * -initialGap,
speed: 0,
targetSpeed: config.desiredSpeedMetersPerSecond,
stopReason: null,
queueAhead: 0,
lastTime: start.clone(),
};
state.distance = (state.distance % route.length + route.length) % route.length;
return {
route,
state,
setMoving(callback) { state.isMoving = callback; },
positions: new Cesium.CallbackProperty((time, result) => routePositionAtDistance(route, state.distance, result), false),
};
});
const update = (clock) => {
const elapsed = Cesium.JulianDate.secondsDifference(clock.currentTime, motions[0]?.state.lastTime || start);
if (!(elapsed > 0) || elapsed > 2) {
for (const motion of motions) Cesium.JulianDate.clone(clock.currentTime, motion.state.lastTime);
return;
}
const grouped = new Map();
assignments.forEach((assignment, index) => {
const key = assignment.routeIndex;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(index);
});
for (const indexes of grouped.values()) {
indexes.sort((a, b) => motions[a].state.distance - motions[b].state.distance);
}
motions.forEach((motion, index) => {
const state = motion.state;
Cesium.JulianDate.clone(clock.currentTime, state.lastTime);
if (state.isMoving && !state.isMoving()) {
state.speed = 0;
state.stopReason = "incident";
return;
}
const route = motion.route;
let target = config.desiredSpeedMetersPerSecond;
let stopReason = null;
const nextStop = nextRouteStop(route, state.distance);
if (nextStop) {
const phase = signalPhase(nextStop.signal.phaseGroup, clock.currentTime, start).active;
const untilStop = (nextStop.distance - state.distance + route.length) % route.length;
if (phase !== "green" && untilStop < Math.max(30, state.speed * config.reactionTimeSeconds + 8)) {
target = Math.min(target, Math.max(0, (untilStop - 1.7) / Math.max(config.reactionTimeSeconds, 0.1)));
stopReason = "traffic-signal";
}
}
const peers = grouped.get(assignments[index].routeIndex) || [];
const peerPosition = peers.indexOf(index);
if (peerPosition >= 0 && peers.length > 1) {
const leaderIndex = peers[(peerPosition + 1) % peers.length];
if (leaderIndex !== index) {
const leader = motions[leaderIndex].state;
const gap = (leader.distance - state.distance + route.length) % route.length - config.vehicleLengthMeters;
const safeGap = config.minimumGapMeters + state.speed * config.reactionTimeSeconds;
if (gap < safeGap + 12) {
target = Math.min(target, Math.max(0, (gap - config.minimumGapMeters) / Math.max(config.reactionTimeSeconds, 0.1)));
if (target < 0.2) stopReason = "leader-gap";
state.queueAhead = leader.stopReason ? 1 : 0;
} else {
state.queueAhead = 0;
}
}
}
state.targetSpeed = target;
const limit = (target >= state.speed ? config.accelerationMetersPerSecondSquared : config.decelerationMetersPerSecondSquared) * elapsed;
state.speed += Math.sign(target - state.speed) * Math.min(Math.abs(target - state.speed), limit);
state.distance = (state.distance + Math.max(0, state.speed) * elapsed) % route.length;
state.stopReason = state.speed < 0.2 ? stopReason : null;
});
};
viewer.clock.onTick.addEventListener(update);
return {
motions,
settings: config,
diagnostics() {
return {
stoppedVehicles: motions.filter((motion) => motion.state.stopReason).length,
queueLength: motions.filter((motion) => motion.state.stopReason === "leader-gap").length,
};
},
};
}
function createTrafficAwarePositions(viewer, route, start, speed, isMoving = () => true) {
const state = { distance: 0, lastTime: start.clone() };
viewer.clock.onTick.addEventListener((clock) => {
@@ -1230,6 +1342,12 @@
"Trees: " + Number(stats.trees || 0),
"Road layer source: " + (metadata.source_geojson ? "osm2streets" : "OSM fallback")
];
if (cruise.simulation) {
const simulation = cruise.simulation.diagnostics();
lines.push("Simulation: native-preview-traffic-simulation/v1");
lines.push("Stopped: " + simulation.stoppedVehicles);
lines.push("Queue: " + simulation.queueLength);
}
if (failed.length) {
lines.push("Failed assets: " + failed.map((asset) => asset.url).join(", "));
}

View File

@@ -0,0 +1,259 @@
"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 = area.outputs.nativeRoadDir;
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 };

View File

@@ -267,7 +267,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics);
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls);
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans);
const junctionFeatures = compileJunctionSurfaces(model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
@@ -653,7 +653,7 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans) {
return { features, byRoadId };
}
function compileConnectors(model, lanes, diagnostics, overrides) {
function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) {
const features = [];
const movements = [];
for (const connection of model.connections.filter((item) => item.enabled)) {
@@ -669,8 +669,12 @@ function compileConnectors(model, lanes, diagnostics, overrides) {
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue;
const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0];
const control = connectorControlPoint(model, connection, from, to);
const coordinates = quadraticCurve(from, control, to, 12);
const plan = junctionPlans.get(connection.nodeId);
// Cross intersections retain the earlier center-node curve while T junctions
// use lane tangents so their through movement does not bow toward the stem.
const coordinates = plan?.segmentIds.size === 4
? quadraticCurve(from, endpointCoordinate(model, connection.fromEndpointId), to, 12)
: connectorCurve(defaultFromLane.coordinates, defaultToLane.coordinates, turn);
const length = lineLengthMeters(coordinates);
const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
const provenance = override ? `override:${override.id}` : connection.provenance;
@@ -712,12 +716,47 @@ function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
if (turn === "uturn") return 0;
return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1)));
}
function connectorControlPoint(model, connection, from, to) {
const node = endpointCoordinate(model, connection.fromEndpointId);
if (!node) return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
// Nearby manual joins may not share exactly the same point. The midpoint
// keeps their curve smooth without rewriting the authoritative OSM geometry.
return node;
function connectorCurve(incoming, outgoing, turn) {
const start = incoming.at(-1);
const end = outgoing[0];
if (turn === "through") return lineCurve(start, end, 12);
const incomingHeading = headingDegrees(incoming.at(-2), start);
const outgoingHeading = headingDegrees(end, outgoing[1]);
const chord = distanceMeters(start, end);
const incomingSpan = distanceMeters(incoming.at(-2), start);
const outgoingSpan = distanceMeters(end, outgoing[1]);
const tangentIntersection = intersectTangentRays(start, end, incomingHeading, outgoingHeading);
const fallbackDistance = Math.min(8, Math.max(.75, Math.min(chord * .42, incomingSpan * .8, outgoingSpan * .8)));
const firstDistance = tangentIntersection && tangentIntersection.incoming >= 0 ? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(.75, incomingSpan * 2.4))) / 3 : fallbackDistance;
const secondDistance = tangentIntersection && tangentIntersection.outgoing >= 0 ? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(.75, outgoingSpan * 2.4))) / 3 : fallbackDistance;
const firstControl = offsetCoordinate(start, incomingHeading, firstDistance);
const secondControl = offsetCoordinate(end, outgoingHeading + 180, secondDistance);
return cubicBezier(start, firstControl, secondControl, end, 12);
}
function intersectTangentRays(start, end, incomingHeading, outgoingHeading) {
const incoming = headingVector(incomingHeading);
const outgoing = headingVector(outgoingHeading);
const delta = project(end, start);
const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
if (Math.abs(cross) < 1e-6) return null;
return {
incoming: (delta[0] * outgoing[1] - delta[1] * outgoing[0]) / cross,
outgoing: (delta[0] * incoming[1] - delta[1] * incoming[0]) / cross,
};
}
function lineCurve(start, end, segments) {
return Array.from({ length: segments + 1 }, (_, index) => interpolate(start, end, index / segments));
}
function cubicBezier(a, firstControl, secondControl, b, segments) {
const result = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments; const u = 1 - t;
result.push([u ** 3 * a[0] + 3 * u * u * t * firstControl[0] + 3 * u * t * t * secondControl[0] + t ** 3 * b[0], u ** 3 * a[1] + 3 * u * u * t * firstControl[1] + 3 * u * t * t * secondControl[1] + t ** 3 * b[1]]);
}
return result;
}
function quadraticCurve(a, control, b, segments) {