feat: add OSM turn lane arrows
This commit is contained in:
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user