feat: add OSM turn lane arrows

This commit is contained in:
2026-08-05 10:17:34 +08:00
parent 9970e3eeef
commit 9a23f74f0f
34 changed files with 1540 additions and 33 deletions

View File

@@ -5,6 +5,7 @@ const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const { JsStreetNetwork } = require("osm2streets-js-node");
const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows");
const {
SCENE_LAYERS,
SCENE_FILE,
@@ -88,6 +89,29 @@ const split = splitLayers(
intersectionCornerSourceMaxDimensionMeters,
osm,
);
const customTurnLaneArrows = buildCustomTurnLaneArrows(osm, {
...config.turnLaneArrows,
network: JSON.parse(network.toJson()),
lanePolygons: JSON.parse(fs.readFileSync(path.join(outDir, "lane_polygons.geojson"), "utf8")).features,
crosswalkStripes: split.crosswalks.features,
stopLines: split.vehicleStopLines.features,
});
const suppressedStandardLaneArrows = suppressNearestStandardLaneArrows(
split.laneArrows.features,
customTurnLaneArrows.features,
);
split.laneArrows.features = split.laneArrows.features.filter((feature) => !suppressedStandardLaneArrows.has(feature));
split.laneArrows.features.push(...customTurnLaneArrows.features);
const turnLaneArrowDiagnostics = {
enabled: config.turnLaneArrows?.enabled === true,
generated: customTurnLaneArrows.features.length,
suppressed_standard_lane_arrows: suppressedStandardLaneArrows.size,
diagnostics: customTurnLaneArrows.diagnostics,
};
fs.writeFileSync(
path.join(outDir, "turn_lane_arrow_diagnostics.json"),
`${JSON.stringify(turnLaneArrowDiagnostics, null, 2)}\n`,
);
for (const layer of SCENE_LAYERS) {
writeJson(path.join(outDir, layerFile(layer)), split[layer.splitKey]);
}
@@ -410,7 +434,7 @@ function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) {
const markings = JSON.parse(fs.readFileSync(path.join(dir, "lane_markings.geojson"), "utf8"));
const intersections = JSON.parse(fs.readFileSync(path.join(dir, "intersection_markings.geojson"), "utf8"));
const network = JSON.parse(fs.readFileSync(path.join(dir, "network.json"), "utf8"));
const crosswalkData = buildCrosswalks(osm);
const crosswalkData = buildCrosswalks(osm, lanePolygons.features);
const serviceWayIds = new Set([...osm.ways.values()]
.filter((way) => way.tags.highway === "service")
.map((way) => way.id));
@@ -452,9 +476,6 @@ function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) {
const conflictsWithCrosswalk = isInAnyPolygon(feature, crosswalkData.zones, "intersects");
if (type === "lane separator" && !conflictsWithCrosswalk) out.laneSeparators.features.push(feature);
if (type === "center line" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) out.centerLines.features.push(feature);
if (type === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) {
out.vehicleStopLines.features.push(feature);
}
if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) {
out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue));
}
@@ -987,6 +1008,43 @@ function featureCenter(feature) {
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
}
function suppressNearestStandardLaneArrows(standardFeatures, customFeatures) {
const groups = new Map();
for (const feature of customFeatures) {
const id = feature.properties?.custom_arrow_id;
if (!id) continue;
const group = groups.get(id) || [];
group.push(feature);
groups.set(id, group);
}
const suppressed = new Set();
for (const parts of groups.values()) {
const center = featureGroupCenter(parts);
if (!center) continue;
let closest = null;
for (const feature of standardFeatures) {
if (suppressed.has(feature)) continue;
const candidateCenter = featureCenter(feature);
if (!candidateCenter) continue;
const distance = lineLengthMeters(center, candidateCenter);
if (distance > 5.5 || (closest && distance >= closest.distance)) continue;
closest = { feature, distance };
}
if (closest) suppressed.add(closest.feature);
}
return suppressed;
}
function featureGroupCenter(features) {
const points = [];
for (const feature of features) collectCoords(feature.geometry?.coordinates, points);
if (!points.length) return null;
return [
(Math.min(...points.map((point) => point[0])) + Math.max(...points.map((point) => point[0]))) / 2,
(Math.min(...points.map((point) => point[1])) + Math.max(...points.map((point) => point[1]))) / 2,
];
}
function pointInPolygon(point, rings) {
if (!rings?.length || !pointInRing(point, rings[0])) return false;
return !rings.slice(1).some((ring) => pointInRing(point, ring));
@@ -1006,31 +1064,29 @@ function pointInRing([x, y], ring) {
return inside;
}
function buildCrosswalks(osm) {
function buildCrosswalks(osm, lanePolygons = []) {
const crossingNodes = markedCrossingNodes(osm);
const clusterCenters = crossingClusters(crossingNodes);
const stripes = emptyCollection();
const stopLines = emptyCollection();
const zones = [];
const stopLineExclusionZones = [];
const fixedCenters = [];
for (const node of crossingNodes) {
const way = findCrossingWay(osm, node.id);
const vector = crossingVector(osm, way, node.id);
if (!vector) continue;
const crosswalk = crosswalkGeometry(node, way, vector, clusterCenters.get(node.id));
const crosswalk = crosswalkGeometry(node, way, vector, clusterCenters.get(node.id), lanePolygons);
if (!crosswalk) continue;
if (fixedCenters.some((center) => pointDistanceMeters(center, crosswalk.center, crosswalk.meters) < 1)) continue;
fixedCenters.push(crosswalk.center);
stripes.features.push(...crosswalk.stripes);
if (crosswalk.stopLine) stopLines.features.push(crosswalk.stopLine);
zones.push({
bbox: featureBounds(crosswalk.zone),
rings: polygonRings(crosswalk.zone.geometry),
});
stopLineExclusionZones.push({
bbox: featureBounds(crosswalk.stopLineExclusionZone),
rings: polygonRings(crosswalk.stopLineExclusionZone.geometry),
});
}
return { stripes, stopLines, zones, stopLineExclusionZones };
return { stripes, stopLines, zones };
}
function markedCrossingNodes(osm) {
@@ -1098,19 +1154,21 @@ function crossingVector(osm, way, nodeId) {
return null;
}
function crosswalkGeometry(node, way, vector, intersectionCenter) {
function crosswalkGeometry(node, way, vector, intersectionCenter, lanePolygons) {
const meters = metersForLat(node.lat);
const stripeLength = crosswalkLengthMeters(way);
const stripeWidth = 0.45;
const gap = 0.45;
const count = 6;
const total = count * stripeWidth + (count - 1) * gap;
const roadUnit = normalizeMetersVector(vector, meters);
if (!roadUnit) return null;
const rawRoadUnit = normalizeMetersVector(vector, meters);
if (!rawRoadUnit) return null;
const provisionalCenter = fixedCrosswalkCenter(node, rawRoadUnit, intersectionCenter, meters);
const laneFrame = crosswalkLaneFrame(way, lanePolygons, provisionalCenter, rawRoadUnit, meters);
const roadUnit = laneFrame?.roadUnit || rawRoadUnit;
const acrossUnit = [-roadUnit[1], roadUnit[0]];
const center = [node.lon, node.lat];
const center = laneFrame?.center || provisionalCenter;
const zoneCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 0.8, total + 0.8, meters);
const stopLineExclusionCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 5, total + 1.5, meters);
const zone = {
type: "Feature",
properties: {
@@ -1119,14 +1177,6 @@ function crosswalkGeometry(node, way, vector, intersectionCenter) {
},
geometry: { type: "Polygon", coordinates: [zoneCoords] },
};
const stopLineExclusionZone = {
type: "Feature",
properties: {
type: "crosswalk stop line exclusion zone",
crossing_node_id: node.id,
},
geometry: { type: "Polygon", coordinates: [stopLineExclusionCoords] },
};
const stripes = [];
for (let i = 0; i < count; i += 1) {
const offset = -total / 2 + stripeWidth / 2 + i * (stripeWidth + gap);
@@ -1144,17 +1194,92 @@ function crosswalkGeometry(node, way, vector, intersectionCenter) {
}
return {
stripes,
center,
meters,
zone,
stopLineExclusionZone,
stopLine: syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters),
stopLine: syntheticStopLine(center, node.id, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters),
};
}
function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) {
function crosswalkLaneFrame(way, lanePolygons, provisionalCenter, rawRoadUnit, meters) {
if (!way || !Array.isArray(lanePolygons)) return null;
const candidates = lanePolygons
.filter((feature) => feature.properties?.type === "Driving" && hasAnyWayId(feature.properties?.osm_way_ids, new Set([way.id])))
.map((feature) => nearestLaneAnchor(feature, provisionalCenter, meters))
.filter(Boolean)
.sort((a, b) => a.distance - b.distance);
if (!candidates.length) return null;
// A way may be represented by adjacent normalized road pieces. Keep the
// nearby cross-section, including every directional lane, not a distant
// piece that happens to retain the same OSM way ID.
const maxDistance = candidates[0].distance + 8;
const anchors = candidates.filter((anchor) => anchor.distance <= maxDistance);
if (!anchors.length) return null;
const center = anchors.reduce((sum, anchor) => [sum[0] + anchor.point[0], sum[1] + anchor.point[1]], [0, 0])
.map((value) => value / anchors.length);
const axis = anchors.reduce((sum, anchor) => {
const sign = anchor.tangent[0] * rawRoadUnit[0] + anchor.tangent[1] * rawRoadUnit[1] >= 0 ? 1 : -1;
return [sum[0] + anchor.tangent[0] * sign, sum[1] + anchor.tangent[1] * sign];
}, [0, 0]);
const roadUnit = normalizeMetersVector(axis, { lon: 1, lat: 1 });
return roadUnit ? { center, roadUnit } : null;
}
function nearestLaneAnchor(feature, point, meters) {
const centerline = drivingLaneCenterline(feature);
if (!centerline) return null;
let best = null;
for (let index = 0; index < centerline.length - 1; index += 1) {
const start = centerline[index];
const end = centerline[index + 1];
const closest = closestPointOnSegment(point, start, end, meters);
const distance = pointDistanceMeters(closest, point, meters);
const tangent = normalizeMetersVector([end[0] - start[0], end[1] - start[1]], meters);
if (tangent && (!best || distance < best.distance)) best = { point: closest, distance, tangent };
}
return best;
}
function drivingLaneCenterline(feature) {
const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null;
if (!ring || ring.length < 5) return null;
const vertices = ring.slice(0, -1);
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
return vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
}
function closestPointOnSegment(point, start, end, meters) {
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
}
function fixedCrosswalkCenter(node, roadUnit, intersectionCenter, meters) {
if (!intersectionCenter) return [node.lon, node.lat];
const center = [intersectionCenter.lon, intersectionCenter.lat];
const candidates = [
addMeters(center, roadUnit, 7, meters),
addMeters(center, roadUnit, -7, meters),
];
const source = [node.lon, node.lat];
return pointDistanceMeters(candidates[0], source, meters) <= pointDistanceMeters(candidates[1], source, meters)
? candidates[0]
: candidates[1];
}
function syntheticStopLine(crosswalkCenter, crossingNodeId, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) {
if (!intersectionCenter) return null;
const offset = stripeLength / 2 + 1.2;
const candidateA = addMeters([node.lon, node.lat], roadUnit, offset, meters);
const candidateB = addMeters([node.lon, node.lat], roadUnit, -offset, meters);
const candidateA = addMeters(crosswalkCenter, roadUnit, offset, meters);
const candidateB = addMeters(crosswalkCenter, roadUnit, -offset, meters);
const stopSide = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters)
? 1
: -1;
@@ -1167,7 +1292,7 @@ function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crossw
properties: {
type: "vehicle stop line",
source: "crosswalk",
crossing_node_id: node.id,
crossing_node_id: crossingNodeId,
highway: way?.tags.highway || null,
stop_side: stopSide === 1 ? "with_way_outside" : "against_way_outside",
},