feat: compile native road lanes and junctions
This commit is contained in:
@@ -123,6 +123,7 @@ function validateOverrides(value, model) {
|
||||
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
|
||||
} else if (item.kind === "junction-connection") {
|
||||
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
|
||||
if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m).");
|
||||
} else throw new Error(`Unsupported override kind: ${item.kind}`);
|
||||
}
|
||||
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
|
||||
@@ -144,14 +145,42 @@ function resolveConnections(endpoints, byNode, overrides, diagnostics) {
|
||||
const departures = items.filter((endpoint) => endpoint.side === "start");
|
||||
for (const arrival of arrivals) for (const departure of departures) {
|
||||
if (arrival.roadId === departure.roadId) continue;
|
||||
const arrivalRoad = endpoints.find((endpoint) => endpoint.id === arrival.id)?.roadId;
|
||||
const departureRoad = endpoints.find((endpoint) => endpoint.id === departure.id)?.roadId;
|
||||
if (sameOsmWay(endpoints, arrivalRoad, departureRoad)) continue;
|
||||
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
|
||||
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
|
||||
}
|
||||
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
|
||||
}
|
||||
// Overrides can add a deliberate movement omitted by the initial inference.
|
||||
// Keep it only when both endpoints still belong to the same OSM junction.
|
||||
for (const override of overrides.overrides.filter((item) => item.kind === "junction-connection")) {
|
||||
const exists = result.some((connection) => connection.fromEndpointId === override.fromEndpointId && connection.toEndpointId === override.toEndpointId);
|
||||
if (exists) continue;
|
||||
const from = endpoints.find((endpoint) => endpoint.id === override.fromEndpointId);
|
||||
const to = endpoints.find((endpoint) => endpoint.id === override.toEndpointId);
|
||||
if (!from || !to || !connectionEndpointsCompatible({ endpoints }, from.id, to.id)) continue;
|
||||
result.push({ id: `connection:${from.id}:${to.id}`, nodeId: from.nodeId, fromEndpointId: from.id, toEndpointId: to.id, enabled: override.enabled, provenance: `override:${override.id}` });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
|
||||
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
|
||||
const wayId = (roadId) => roadId.split(":")[1];
|
||||
return wayId(firstRoadId) === wayId(secondRoadId);
|
||||
}
|
||||
function connectionEndpointsCompatible(model, fromId, toId) {
|
||||
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
|
||||
const to = model.endpoints.find((endpoint) => endpoint.id === toId);
|
||||
if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false;
|
||||
if (from.nodeId === to.nodeId) return true;
|
||||
const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180);
|
||||
const dy = (from.coordinate[1] - to.coordinate[1]) * 111320;
|
||||
return Math.hypot(dx, dy) <= 35;
|
||||
}
|
||||
|
||||
function compileGeometry(model) {
|
||||
const diagnostics = [...model.diagnostics];
|
||||
const features = [];
|
||||
@@ -166,11 +195,148 @@ function compileGeometry(model) {
|
||||
if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; }
|
||||
features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
}
|
||||
const junctionFeatures = ordinaryJunctionFeatures(model, diagnostics);
|
||||
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, diagnostics };
|
||||
const lanes = compileLaneCenterlines(model, diagnostics);
|
||||
const connectors = compileConnectors(model, lanes, diagnostics);
|
||||
const junctionFeatures = compileJunctionSurfaces(model, lanes, connectors, diagnostics);
|
||||
validateConnectorContainment(connectors, junctionFeatures, diagnostics);
|
||||
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectors }, diagnostics };
|
||||
}
|
||||
|
||||
function ordinaryJunctionFeatures(model, diagnostics) {
|
||||
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
|
||||
const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature]));
|
||||
for (const connector of connectors) {
|
||||
const junction = junctionByNode.get(connector.properties.node_id);
|
||||
if (!junction) continue;
|
||||
const ring = junction.geometry.coordinates[0];
|
||||
if (!connector.geometry.coordinates.every((point) => pointInPolygon(point, ring))) {
|
||||
diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(point, ring) {
|
||||
for (let index = 1; index < ring.length; index += 1) if (pointOnSegment(point, ring[index - 1], ring[index])) return true;
|
||||
let inside = false;
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const a = ring[index]; const b = ring[previous];
|
||||
const intersect = a[1] > point[1] !== b[1] > point[1] && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0];
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
function pointOnSegment(point, a, b) {
|
||||
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
|
||||
if (Math.abs(cross) > 1e-12) return false;
|
||||
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
|
||||
}
|
||||
|
||||
function compileLaneCenterlines(model, diagnostics) {
|
||||
const features = [];
|
||||
const byRoadId = new Map();
|
||||
for (const road of model.roads) {
|
||||
const lanes = [];
|
||||
const laneWidth = road.widthMeters / road.laneCount;
|
||||
const siblings = model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
||||
const opposite = siblings.find((item) => item.id !== road.id);
|
||||
// OSM centerline is the shared carriageway center. On a two-way road,
|
||||
// offset each directed carriageway to its own side before placing lanes.
|
||||
const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0;
|
||||
for (let index = 0; index < road.laneCount; index += 1) {
|
||||
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
|
||||
// driver's left so tag positions and generated lane IDs have one meaning.
|
||||
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
|
||||
const coordinates = offsetLine(road.centerline, offset);
|
||||
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
|
||||
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
|
||||
lanes.push(lane);
|
||||
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, source: "native-road-lane-centerline/v1" }, geometry: { type: "LineString", coordinates } });
|
||||
}
|
||||
byRoadId.set(road.id, lanes);
|
||||
}
|
||||
return { features, byRoadId };
|
||||
}
|
||||
|
||||
function compileConnectors(model, lanes, diagnostics) {
|
||||
const features = [];
|
||||
for (const connection of model.connections.filter((item) => item.enabled)) {
|
||||
const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
|
||||
const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
|
||||
const fromLanes = lanes.byRoadId.get(fromRoad?.id) || [];
|
||||
const toLanes = lanes.byRoadId.get(endpointRoadId(model, connection.toEndpointId)) || [];
|
||||
if (!fromLanes.length || !toLanes.length) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-missing-lane", "转向连接缺少可用车道中心线。", endpointCoordinate(model, connection.fromEndpointId))); continue; }
|
||||
for (let index = 0; index < fromLanes.length; index += 1) {
|
||||
const turn = connectionTurn(fromRoad, toRoad);
|
||||
if (!laneAllowsTurn(fromRoad, index, turn)) continue;
|
||||
const targetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length);
|
||||
const from = fromLanes[index].coordinates.at(-1); const to = toLanes[targetIndex].coordinates[0];
|
||||
const control = connectorControlPoint(model, connection, from, to);
|
||||
const coordinates = quadraticCurve(from, control, to, 12);
|
||||
const length = lineLengthMeters(coordinates);
|
||||
if (length < 0.4) continue;
|
||||
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,已跳过;请检查路口拓扑或人工连接。", from)); continue; }
|
||||
features.push({ type: "Feature", properties: { native_id: `connector:${connection.id}:lane/${index + 1}`, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: fromLanes[index].id, to_lane_id: toLanes[targetIndex].id, turn, provenance: connection.provenance }, geometry: { type: "LineString", coordinates } });
|
||||
}
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
function endpointRoadId(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId; }
|
||||
function endpointCoordinate(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate; }
|
||||
function connectionTurn(fromRoad, toRoad) {
|
||||
if (!fromRoad || !toRoad) return "unknown";
|
||||
const incoming = headingDegrees(fromRoad.centerline.at(-2), fromRoad.centerline.at(-1));
|
||||
const outgoing = headingDegrees(toRoad.centerline[0], toRoad.centerline[1]);
|
||||
const delta = ((outgoing - incoming + 540) % 360) - 180;
|
||||
if (Math.abs(delta) >= 150) return "uturn";
|
||||
if (Math.abs(delta) <= 30) return "through";
|
||||
return delta > 0 ? "right" : "left";
|
||||
}
|
||||
function laneAllowsTurn(road, zeroIndex, turn) {
|
||||
if (!road) return true;
|
||||
const tag = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
|
||||
if (!tag) return true;
|
||||
const lanes = String(tag).split("|").map((lane) => lane.split(";").map((value) => value.trim().replace("slight_", "")).filter(Boolean));
|
||||
const allowed = lanes[zeroIndex];
|
||||
return !allowed || allowed.includes(turn) || turn === "uturn" && allowed.includes("reverse");
|
||||
}
|
||||
function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
|
||||
if (turn === "left") return 0;
|
||||
if (turn === "right") return targetCount - 1;
|
||||
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 quadraticCurve(a, control, b, segments) {
|
||||
const result = [];
|
||||
for (let index = 0; index <= segments; index += 1) {
|
||||
const t = index / segments; const u = 1 - t;
|
||||
result.push([u * u * a[0] + 2 * u * t * control[0] + t * t * b[0], u * u * a[1] + 2 * u * t * control[1] + t * t * b[1]]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function offsetLine(line, offsetMeters) {
|
||||
if (line.length < 2) return null;
|
||||
const origin = line[0]; const points = line.map((point) => project(point, origin)); const result = [];
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)];
|
||||
const dx = next[0] - previous[0]; const dy = next[1] - previous[1]; const length = Math.hypot(dx, dy);
|
||||
if (length < 0.01) return null;
|
||||
result.push(unproject([points[index][0] - dy / length * offsetMeters, points[index][1] + dx / length * offsetMeters], origin));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
|
||||
|
||||
function compileJunctionSurfaces(model, lanes, connectors, diagnostics) {
|
||||
const byNode = new Map();
|
||||
for (const endpoint of model.endpoints) {
|
||||
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
|
||||
@@ -180,15 +346,80 @@ function ordinaryJunctionFeatures(model, diagnostics) {
|
||||
for (const [nodeId, endpoints] of byNode) {
|
||||
const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1]));
|
||||
if (wayIds.size < 3 || wayIds.size > 4) continue;
|
||||
const node = endpoints[0].coordinate;
|
||||
const roads = endpoints.map((endpoint) => model.roads.find((road) => road.id === endpoint.roadId));
|
||||
const radius = Math.max(...roads.map((road) => road.widthMeters)) * 0.65;
|
||||
const ring = circleRing(endpoints[0].coordinate, radius, 16);
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), rule: "ordinary-junction-disc/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "Generated a conservative ordinary junction surface; connector geometry is deferred.", endpoints[0].coordinate));
|
||||
const cutbackMeters = Math.max(...roads.map((road) => road.widthMeters)) * 1.4;
|
||||
const boundary = junctionBoundary(model, endpoints, node, cutbackMeters);
|
||||
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
|
||||
if (boundary.length < 3 || !junctionConnectors.length) {
|
||||
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
|
||||
continue;
|
||||
}
|
||||
const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
|
||||
const ring = [...envelope, envelope[0]];
|
||||
if (hasSelfIntersection(ring)) {
|
||||
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
|
||||
continue;
|
||||
}
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), cutback_m: cutbackMeters, connector_count: junctionConnectors.length, rule: "junction-cutback-envelope/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function junctionBoundary(model, endpoints, node, cutbackMeters) {
|
||||
const points = [];
|
||||
for (const endpoint of endpoints) {
|
||||
const road = model.roads.find((item) => item.id === endpoint.roadId);
|
||||
if (!road) continue;
|
||||
const line = endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline;
|
||||
const cutback = pointAlongLine(line, cutbackMeters);
|
||||
if (!cutback) continue;
|
||||
const heading = headingAtEndpoint(line);
|
||||
const half = road.widthMeters / 2;
|
||||
points.push(offsetCoordinate(cutback, heading + 90, half));
|
||||
points.push(offsetCoordinate(cutback, heading - 90, half));
|
||||
}
|
||||
return sortAround(node, points);
|
||||
}
|
||||
|
||||
function pointAlongLine(line, meters) {
|
||||
let remaining = meters;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const length = distanceMeters(line[index - 1], line[index]);
|
||||
if (length >= remaining) return interpolate(line[index - 1], line[index], remaining / length);
|
||||
remaining -= length;
|
||||
}
|
||||
return line.at(-1);
|
||||
}
|
||||
|
||||
function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
|
||||
function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
|
||||
function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
|
||||
function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
|
||||
function convexHull(points) {
|
||||
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (unique.length < 3) return unique;
|
||||
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
const lower = []; for (const point of unique) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop(); lower.push(point); }
|
||||
const upper = []; for (const point of [...unique].reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop(); upper.push(point); }
|
||||
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||||
}
|
||||
function interpolate(a, b, ratio) { return [a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio]; }
|
||||
function distanceMeters(a, b) { const dx = (b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180); const dy = (b[1] - a[1]) * 111320; return Math.hypot(dx, dy); }
|
||||
function hasSelfIntersection(ring) {
|
||||
for (let first = 0; first < ring.length - 1; first += 1) for (let second = first + 1; second < ring.length - 1; second += 1) {
|
||||
if (Math.abs(first - second) <= 1 || first === 0 && second === ring.length - 2) continue;
|
||||
if (segmentsIntersect(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function segmentsIntersect(a, b, c, d) {
|
||||
const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
||||
const abC = cross(a, b, c); const abD = cross(a, b, d); const cdA = cross(c, d, a); const cdB = cross(c, d, b);
|
||||
return (abC > 0 && abD < 0 || abC < 0 && abD > 0) && (cdA > 0 && cdB < 0 || cdA < 0 && cdB > 0);
|
||||
}
|
||||
|
||||
function circleRing(center, radius, segments) {
|
||||
const origin = center;
|
||||
const ring = [];
|
||||
|
||||
Reference in New Issue
Block a user