"use strict"; const fs = require("fs"); const path = require("path"); const { arrowRingsAt, normalizeManeuver } = require("./turn-lane-arrows"); const OVERRIDE_SCHEMA = "native-road-overrides/v1"; const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]); const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 }; const DEFAULT_SIDEWALK_WIDTH_METERS = 2; const DIRECTION_ARROW_INTERVAL_METERS = 32; const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14; const STOP_LINE_OFFSET_METERS = 2.7; const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25; const CENTER_LINE_DASH_LENGTH_METERS = 2; const CENTER_LINE_DASH_GAP_METERS = 2; const CENTER_LINE_WIDTH_METERS = .25; const CENTER_LINE_SOLID_OVERLAP_METERS = .04; const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1; const CENTER_LINE_COLORS = new Set(["yellow", "white"]); const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]); function parseOsmRoads(xml) { const nodes = new Map(); const crossingNodes = []; for (const match of xml.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { const attrs = xmlAttrs(match[1]); if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue; const coordinate = [Number(attrs.lon), Number(attrs.lat)]; if (!coordinate.every(Number.isFinite)) continue; const id = String(attrs.id); const tags = parseTags(match[2] || ""); nodes.set(id, coordinate); if (tags.highway === "crossing" && !["no", "none", "unmarked"].includes(tags["crossing:markings"])) crossingNodes.push({ id, coordinate, tags }); } const ways = []; for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { const attrs = xmlAttrs(match[1]); const body = match[2]; const tags = parseTags(body); if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue; const refs = [...body.matchAll(/]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean); const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean); if (coords.length < 2 || coords.length !== refs.length) continue; ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags }); } return { nodes, ways, crossingNodes }; } function compileRoadModel(xml, overrides) { const parsed = parseOsmRoads(xml); const diagnostics = []; const roads = []; const endpoints = []; const byNode = new Map(); const sharedNodeWayIds = new Map(); for (const way of parsed.ways) for (const nodeId of new Set(way.refs)) { if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set()); sharedNodeWayIds.get(nodeId).add(way.id); } for (const sourceWay of parsed.ways) { const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds); for (const way of segments) { const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"]; for (const direction of directions) { const base = roadAttributes(way.tags, direction); const id = `road:way/${way.id}${way.segmentIndex === null ? "" : `:segment/${way.segmentIndex}`}:${direction}`; const road = { id, osmWayIds: [way.id], segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`, sourceRoadId: `road:way/${way.id}:${direction}`, direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] }; applyRoadOverrides(road, overrides, diagnostics); roads.push(road); for (const side of ["start", "end"]) { const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1]; const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction }; endpoints.push(endpoint); if (!byNode.has(nodeId)) byNode.set(nodeId, []); byNode.get(nodeId).push(endpoint); } } } } const connections = resolveConnections(endpoints, byNode, overrides, diagnostics); const extent = roadExtent(roads); for (const [nodeId, items] of byNode) { if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) { const endpoint = items[0]; diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id, manualCandidates: nearbyManualCandidates(endpoints, endpoint) }); } } const crossings = parsed.crossingNodes.map((crossing) => ({ ...crossing, osmWayIds: parsed.ways.filter((way) => way.refs.includes(crossing.id)).map((way) => way.id) })); return { schema: "native-road-model/v1", roads, endpoints, connections, crossings, diagnostics }; } function splitWayAtSharedNodes(way, sharedNodeWayIds) { const splitIndexes = [0]; for (let index = 1; index < way.refs.length - 1; index += 1) if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index); splitIndexes.push(way.refs.length - 1); if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }]; return splitIndexes.slice(1).map((end, index) => { const start = splitIndexes[index]; return { ...way, refs: way.refs.slice(start, end + 1), coords: way.coords.slice(start, end + 1), segmentIndex: index + 1 }; }); } function roadExtent(roads) { const points = roads.flatMap((road) => road.centerline); return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) }; } function distanceToExtentEdgeMeters(point, extent) { const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180); return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320); } function roadAttributes(tags, direction) { const directional = direction === "forward" ? "forward" : "backward"; const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null); const parsedLanes = positiveInteger(laneTag); const totalLanes = positiveInteger(tags.lanes); const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1); const parsedWidth = positiveNumber(tags.width); const forwardLanes = positiveInteger(tags["lanes:forward"]); const backwardLanes = positiveInteger(tags["lanes:backward"]); const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes; // `width` describes the whole OSM way. A directional road receives its lane // share; absent width falls back to a realistic per-lane carriageway width. const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25; return { laneCount: lanes, widthMeters: width, sidewalkLeft: sidewalkState(tags, direction, "left"), sidewalkRight: sidewalkState(tags, direction, "right"), provenance: { laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes", widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane", }, }; } function sidewalkState(tags, direction, side) { const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left"; const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk; return value === "both" || value === "yes" || value === osmSide; } function loadOverrides(file) { if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] }; return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8"))); } function validateOverrides(value, model) { if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`); const ids = new Set(); const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null; const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null; const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null; const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null; for (const item of value.overrides) { if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id."); ids.add(item.id); if (item.kind === "road") { if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`); for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`); 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 if (item.kind === "lane-connection") { if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override."); } else if (item.kind === "center-line-style") { if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override."); } else if (item.kind === "lane-separator-style") { if (typeof item.roadId !== "string" || (roadIds && !roadIds.has(item.roadId)) || !Number.isInteger(item.leftLaneIndex) || item.rightLaneIndex !== item.leftLaneIndex + 1 || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid lane separator style override."); } else throw new Error(`Unsupported override kind: ${item.kind}`); } return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; } function applyRoadOverrides(road, overrides, diagnostics) { const matching = overrides.overrides.filter((entry) => entry.kind === "road" && (entry.roadId === road.sourceRoadId || entry.roadId === road.id)); // A legacy whole-way edit remains the baseline; a segment-specific edit can // deliberately refine it after the compiler has introduced split segments. matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id)); for (const item of matching) { for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key]; road.appliedOverrideIds.push(item.id); for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`; } if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0])); } function resolveConnections(endpoints, byNode, overrides, diagnostics) { const result = []; for (const [nodeId, items] of byNode) { const arrivals = items.filter((endpoint) => endpoint.side === "end"); 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 roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId; const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, ""); return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || 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 || sameOsmWay(model.endpoints, 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 nearbyManualCandidates(endpoints, from) { return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 })); } function compileGeometry(model, overrides = { overrides: [] }) { const diagnostics = [...model.diagnostics]; const junctionPlans = compileJunctionPlans(model); const features = []; const emittedSegments = new Set(); for (const road of model.roads) { const segmentKey = road.segmentId; if (emittedSegments.has(segmentKey)) continue; emittedSegments.add(segmentKey); const directions = model.roads.filter((item) => item.segmentId === segmentKey); const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); // Road and junction asphalt share one final material. Keep the carriageway // continuous through the semantic junction overlay; cutting it back creates // visible wedges/gaps without improving the rendered result. const ring = roadRing(road.centerline, totalWidth); 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; } const surfaceId = road.segmentId.endsWith("/0") ? `surface:way/${road.osmWayIds.join(",")}` : `surface:${segmentKey}`; features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); } const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans); const edgeLines = compileEdgeLines(model, junctionPlans); const controls = compileControlMarkings(model, lanes, diagnostics); 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 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 }; } function compileEdgeLines(model, junctionPlans) { const features=[]; for (const road of model.roads) { const line=trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); for (const side of [-1,1]) { const ring=roadRing(offsetLine(line, side * road.widthMeters / 2), .12); if (ring) features.push({type:"Feature",properties:{native_id:`edge-line:${road.id}:${side<0?"right":"left"}`,road_id:road.id,side:side<0?"right":"left",color:"white",pattern:"solid",provenance:"native-road-edge-line/v1"},geometry:{type:"Polygon",coordinates:[ring]}}); } } return features; } function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) { const features = []; const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; const segments = new Map(); for (const road of model.roads) { if (!segments.has(road.segmentId)) segments.set(road.segmentId, []); segments.get(road.segmentId).push(road); } for (const [segmentId, roads] of segments) { const forward = roads.find((road) => road.direction === "forward"); const backward = roads.find((road) => road.direction === "backward"); if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue; const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); const length = lineLengthMeters(line); if (line.length < 2 || !Number.isFinite(length)) { diagnostics.push(diagnostic("warning", segmentId, forward.osmWayIds, "invalid-center-line", "双向道路无法生成有效道路中心虚线。", forward.centerline[0])); continue; } const style = centerLineStyle(overrides, segmentId); const gap = style.pattern === "solid" ? 0 : CENTER_LINE_DASH_GAP_METERS; const markLength = CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === "solid" ? CENTER_LINE_SOLID_OVERLAP_METERS : 0); for (let start = 0, dashIndex = 1; start + markLength <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) { const placement = pointAndAxisAlongLine(line, start + markLength / 2); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, 0); const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0); if (ringsOverlapControl([clearanceRing], controlFeatures)) continue; features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); } } return features; } function centerLineStyle(overrides, segmentId) { const override = overrides.overrides.find((item) => item.kind === "center-line-style" && item.segmentId === segmentId); return override ? { color: override.color, pattern: override.pattern } : { color: "yellow", pattern: "dashed" }; } function compileControlMarkings(model, lanes, diagnostics) { const crosswalks = []; const stopLines = []; const arrivalEndpointIds = new Set(model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId)); for (const crossing of model.crossings || []) { const candidates = model.roads.filter((road) => crossing.osmWayIds.includes(road.osmWayIds[0])).flatMap((road) => (lanes.byRoadId.get(road.id) || []).map((lane) => ({ road, lane, placement: nearestLanePlacement(lane.coordinates, crossing.coordinate), junctionDistanceMeters: distanceMeters(crossing.coordinate, road.centerline.at(-1)) })).filter((item) => item.placement)); const candidate = candidates.sort((a, b) => a.placement.distance - b.placement.distance)[0]; if (!candidate || candidate.placement.distance > 12) { diagnostics.push(diagnostic("warning", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-native-lane", "人行横道无法匹配到安全的原生车道,未生成标线。", crossing.coordinate)); continue; } const approach = candidates.filter((item) => arrivalEndpointIds.has(`endpoint:${item.road.id}:end`) && item.junctionDistanceMeters > STOP_LINE_OFFSET_METERS && item.junctionDistanceMeters <= STOP_LINE_MAX_APPROACH_DISTANCE_METERS).sort((a, b) => a.junctionDistanceMeters - b.junctionDistanceMeters || a.placement.distance - b.placement.distance)[0]; const crosswalkCandidate = approach || candidate; const { axis } = crosswalkCandidate.placement; const across = [-axis[1], axis[0]]; for (let index = 0; index < 6; index += 1) crosswalks.push(controlFeature("crosswalk", crossing, crosswalkCandidate, index + 1, rectangleAt(crossing.coordinate, axis, across, 3, .45, -2.25 + index * .9))); if (!approach) { diagnostics.push(diagnostic("info", `crossing:node/${crossing.id}`, [crossing.id], "crossing-no-safe-stop-line", "人行横道没有可确认的路口进口车道,保留斑马线但未生成停止线。", crossing.coordinate)); continue; } const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, crossing.coordinate); const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0]; const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1]; const laneCenterAtCrossing = offsetByMeters(crossing.coordinate, across, lateralOffset); const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS); stopLines.push(controlFeature("stop-line", crossing, approach, 1, rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, .45, 0))); } return { crosswalks, stopLines }; } function nearestLanePlacement(line, target) { let best = null; let traversedMeters = 0; for (let index = 1; index < line.length; index += 1) { const a = line[index - 1]; const b = line[index]; const vector = project(b, a); const length = Math.hypot(...vector); if (!length) continue; const relative = project(target, a); const ratio = Math.max(0, Math.min(1, (relative[0] * vector[0] + relative[1] * vector[1]) / (length * length))); const point = interpolate(a, b, ratio); const distance = distanceMeters(point, target); if (!best || distance < best.distance) best = { point, axis: [vector[0] / length, vector[1] / length], distance, distanceToEndMeters: lineLengthMeters(line) - traversedMeters - length * ratio }; traversedMeters += length; } return best; } function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meters, axis[1] * meters], point); } function rectangleAt(center, axis, across, length, width, offset) { const shifted = offsetByMeters(center, across, offset); const corners = [[-length / 2, -width / 2], [length / 2, -width / 2], [length / 2, width / 2], [-length / 2, width / 2]].map(([forward, side]) => unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted)); return [...corners, corners[0]]; } function controlFeature(kind, crossing, candidate, part, ring) { const stop = kind === "stop-line"; return { type: "Feature", properties: { native_id: `${kind}:node/${crossing.id}:${part}`, crossing_node_id: crossing.id, road_id: candidate.road.id, lane_id: candidate.lane.id, osm_way_ids: candidate.road.osmWayIds.join(","), direction: candidate.road.direction, placement_method: "native-lane-nearest-point/v1", provenance: stop ? "native-road-stop-line/v1" : "native-road-crosswalk/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }; } function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls) { const separators = []; const directionArrows = []; const turnArrows = []; const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; for (const road of model.roads) { const roadLanes = lanes.byRoadId.get(road.id) || []; for (let index = 1; index < roadLanes.length; index += 1) { const left = roadLanes[index - 1].coordinates; const right = roadLanes[index].coordinates; if (left.length !== right.length) continue; const centerline = left.map((point, pointIndex) => [(point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2]); const style = laneSeparatorStyle(overrides, road.id, index, index + 1); const properties = { road_id: road.id, left_lane_index: index, right_lane_index: index + 1, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" }; if (style.pattern === "solid") { const ring = roadRing(centerline, 0.12); if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); } else for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) { const placement = pointAndAxisAlongLine(centerline, distance); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0); separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); } } for (const lane of roadLanes) directionArrows.push(...directionArrowFeatures(road, lane, controlFeatures, diagnostics)); const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"]; const maneuvers = turns ? String(turns).split("|") : []; for (let index = 0; index < roadLanes.length; index += 1) { const lane = roadLanes[index]; const explicitManeuver = maneuvers[index]; if (!explicitManeuver) continue; const maneuver = normalizeManeuver(explicitManeuver); if (!lane) { diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "turn-arrow-lane-missing", "转向标签引用了不存在的车道,未生成箭头。", road.centerline.at(-1))); continue; } if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-unsupported", "转向标签不在当前已测试的箭头集合中,未生成箭头。", lane.coordinates.at(-1))); continue; } if (lineLengthMeters(lane.coordinates) < 8) { diagnostics.push(diagnostic("warning", lane.id, road.osmWayIds, "turn-arrow-no-safe-placement", "驶入路口前的车道过短,未生成转向箭头。", lane.coordinates.at(-1))); continue; } const previous = lane.coordinates.at(-2); const end = lane.coordinates.at(-1); const meters = project(end, end); const vector = project(previous, end); const length = Math.hypot(-vector[0], -vector[1]); const axis = length ? [-vector[0] / length, -vector[1] / length] : null; const placement = axis ? [6, 10, 14, 18, 22].find((distance) => distance < lineLengthMeters(lane.coordinates) - 2 && !ringsOverlapControl(arrowRingsAt(maneuver, pointAlongLine([...lane.coordinates].reverse(), distance), axis), controlFeatures)) : null; if (!placement) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "turn-arrow-control-conflict", "转向箭头会压住斑马线或停止线,未生成该箭头。", lane.coordinates.at(-1))); continue; } const center = pointAlongLine([...lane.coordinates].reverse(), placement); const rings = arrowRingsAt(maneuver, center, axis); if (!rings.length) continue; for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: placement, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } }); } } return { separators, directionArrows, turnArrows }; } function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) { const value = overrides.overrides.find((item) => item.kind === "lane-separator-style" && item.roadId === roadId && item.leftLaneIndex === leftLaneIndex && item.rightLaneIndex === rightLaneIndex); return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "dashed" }; } function directionArrowFeatures(road, lane, controlFeatures, diagnostics) { const length = lineLengthMeters(lane.coordinates); const features = []; for (let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1; distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS; distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1) { const placement = pointAndAxisAlongLine(lane.coordinates, distance); if (!placement) continue; const rings = arrowRingsAt("through", placement.point, placement.axis); if (ringsOverlapControl(rings, controlFeatures)) { diagnostics.push(diagnostic("info", lane.id, road.osmWayIds, "direction-arrow-control-conflict", "默认直行箭头会压住斑马线或停止线,已跳过该位置。", placement.point)); continue; } for (let part = 0; part < rings.length; part += 1) features.push({ type: "Feature", properties: { native_id: `direction-arrow:${lane.id}:${sequence}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver: "through", sequence, distance_along_lane_meters: Math.round(distance * 10) / 10, placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS, provenance: "native-road-direction-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } }); } return features; } function ringsOverlapControl(rings, controls) { return rings.some((ring) => controls.some((feature) => ringsOverlap(ring, feature.geometry.coordinates[0]))); } function ringsOverlap(first, second) { const bounds = (ring) => [Math.min(...ring.map((point) => point[0])), Math.min(...ring.map((point) => point[1])), Math.max(...ring.map((point) => point[0])), Math.max(...ring.map((point) => point[1]))]; const a = bounds(first); const b = bounds(second); if (a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]) return false; if (first.some((point) => pointInPolygon(point, second)) || second.some((point) => pointInPolygon(point, first))) return true; return first.slice(1).some((point, index) => second.slice(1).some((other, otherIndex) => segmentsIntersect(first[index], point, second[otherIndex], other))); } function compileSidewalkSurfaces(model, diagnostics, junctionPlans) { const features = []; const byWay = new Map(); for (const road of model.roads) { const key = road.segmentId; if (!byWay.has(key)) byWay.set(key, []); byWay.get(key).push(road); } for (const [wayKey, directions] of byWay) { const forward = directions.find((road) => road.direction === "forward") || directions[0]; const backward = directions.find((road) => road.id !== forward.id); const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0); const sides = [ ["left", forward.sidewalkLeft || Boolean(backward?.sidewalkRight)], ["right", forward.sidewalkRight || Boolean(backward?.sidewalkLeft)], ]; for (const [side, enabled] of sides) { if (!enabled) continue; const centerline = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans); const ring = sidewalkRing(centerline, totalWidth / 2, totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS, side === "left" ? 1 : -1); if (!ring) { diagnostics.push(diagnostic("warning", forward.id, forward.osmWayIds, "invalid-sidewalk-surface", "无法为该道路生成连续人行道面。", forward.centerline[0])); continue; } const sidewalkId = forward.segmentId.endsWith("/0") ? `sidewalk:way/${forward.osmWayIds.join(",")}:${side}` : `sidewalk:${wayKey}:${side}`; features.push({ type: "Feature", properties: { native_id: sidewalkId, osm_way_ids: forward.osmWayIds.join(","), source_road_id: forward.sourceRoadId, side, width_m: DEFAULT_SIDEWALK_WIDTH_METERS, directional_road_ids: directions.map((road) => road.id).join(","), provenance: "native-road-sidewalk/v1", override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); } } features.push(...compileSidewalkCorners(model, junctionPlans)); return features; } function compileSidewalkCorners(model, junctionPlans) { const result = []; for (const [nodeId, plan] of junctionPlans) { const candidates = []; for (const approach of plan.approaches) { const directions = model.roads.filter((road) => road.segmentId === approach.segmentId); const forward = directions.find((road) => road.direction === "forward") || directions[0]; if (!forward) continue; const outwardIsForward = forward.sourceNodeIds[0] === nodeId; const sideStates = outwardIsForward ? { left: forward.sidewalkLeft, right: forward.sidewalkRight } : { left: forward.sidewalkRight, right: forward.sidewalkLeft }; const cutback = pointAlongLine(approach.line, plan.cutbackMeters); if (!cutback) continue; const heading = headingAtEndpoint(approach.line); const halfWidth = approach.widthMeters / 2; for (const [side, enabled] of Object.entries(sideStates)) { if (!enabled) continue; // offsetLine's positive normal is driver's left, which is heading -90 // in this north-based heading convention. const sideHeading = heading + (side === "left" ? -90 : 90); candidates.push({ wayKey: approach.segmentId, sourceWayKey: forward.osmWayIds.join(","), side, normalDegrees: sideHeading, curb: offsetCoordinate(cutback, sideHeading, halfWidth), outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS), }); } } candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb)); for (let index = 0; index < candidates.length; index += 1) { const first = candidates[index]; const second = candidates[(index + 1) % candidates.length]; if (first.wayKey === second.wayKey) continue; const ring = [first.curb, first.outer, second.outer, second.curb, first.curb]; if (hasSelfIntersection(ring)) continue; if (first.sourceWayKey === second.sourceWayKey && (!samePhysicalSide(first, second) || cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches))) continue; result.push({ type: "Feature", properties: { native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`, osm_node_id: nodeId, kind: "corner", width_m: DEFAULT_SIDEWALK_WIDTH_METERS, provenance: "native-road-sidewalk-corner/v1", }, geometry: { type: "Polygon", coordinates: [ring] }, }); } } return result; } function samePhysicalSide(first, second) { const radians = (first.normalDegrees - second.normalDegrees) * Math.PI / 180; return Math.cos(radians) >= 0.98; } function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) { const center = ring.slice(0, -1).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]); return approaches.filter((approach) => approach.sourceWayKey !== sourceWayKey).some((approach) => { const carriageway = roadRing(approach.line, approach.widthMeters); return carriageway && pointInPolygon(center, carriageway); }); } 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, junctionPlans) { 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.segmentId === road.segmentId); 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(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), 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, overrides) { const features = []; const movements = []; 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); const defaultTargetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length); const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex]; 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 length = lineLengthMeters(coordinates); const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`; const provenance = override ? `override:${override.id}` : connection.provenance; const connectorId = `connector:${id}`; const geometryStatus = length < .4 ? "continuous" : length > 80 ? "deferred-too-long" : "connector"; const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus }; if (length < .4) { movements.push(movement); continue; } if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; } features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } }); movements.push(movement); } } return { features, movements }; } function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); } 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 polygonAreaMeters(ring) { if (ring.length < 3) return 0; const origin = ring[0]; const points = ring.map((point) => project(point, origin)); let twiceArea = 0; for (let index = 0; index < points.length; index += 1) { const next = points[(index + 1) % points.length]; twiceArea += points[index][0] * next[1] - next[0] * points[index][1]; } return Math.abs(twiceArea) / 2; } function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) { const result = []; for (const [nodeId, plan] of junctionPlans) { const { segmentIds, node, approaches, cutbackMeters, boundary } = plan; const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId); const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId); if (boundary.length < 3 || !junctionMovements.length) { diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node)); continue; } const approachAreaMeters = polygonAreaMeters(boundary); let ring = [...boundary, boundary[0]]; let boundaryMode = "approach-envelope"; if (hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInPolygon(point, ring)))) { const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]); ring = [...envelope, envelope[0]]; boundaryMode = "connector-convex-fallback"; } if (hasSelfIntersection(ring)) { diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node)); continue; } const surfaceAreaMeters = polygonAreaMeters(ring); const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null; result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } }); if (boundaryMode === "connector-convex-fallback") diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-connector-envelope-fallback", "路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。", node)); diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node)); } return result; } function compileJunctionPlans(model) { const byNode = new Map(); for (const endpoint of model.endpoints) { if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); byNode.get(endpoint.nodeId).push(endpoint); } const plans = new Map(); for (const [nodeId, endpoints] of byNode) { const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, ""))); if (segmentIds.size < 3 || segmentIds.size > 4) continue; const approaches = junctionApproaches(model, endpoints); if (approaches.length !== segmentIds.size) continue; const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4; const node = endpoints[0].coordinate; const boundary = junctionBoundary(approaches, node, cutbackMeters); if (boundary.length < 3) continue; plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary }); } return plans; } function junctionApproaches(model, endpoints) { const groups = new Map(); for (const endpoint of endpoints) { const road = model.roads.find((item) => item.id === endpoint.roadId); if (!road) continue; const key = road.segmentId; if (!groups.has(key)) groups.set(key, []); groups.get(key).push({ endpoint, road }); } return [...groups.values()].map((directions) => { const { endpoint, road } = directions[0]; return { segmentId: road.segmentId, sourceWayKey: road.osmWayIds.join(","), line: endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline, roadIds: directions.map((item) => item.road.id), widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0) }; }); } function junctionBoundary(approaches, node, cutbackMeters) { const points = []; for (const approach of approaches) { const cutback = pointAlongLine(approach.line, cutbackMeters); if (!cutback) continue; const heading = headingAtEndpoint(approach.line); const half = approach.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 pointAndAxisAlongLine(line, meters) { let remaining = meters; for (let index = 1; index < line.length; index += 1) { const start = line[index - 1]; const end = line[index]; const length = distanceMeters(start, end); if (length < 0.01) continue; if (length >= remaining) { const vector = project(end, start); return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] }; } remaining -= length; } return null; } function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) { const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0; const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0; if (!startCutback && !endCutback) return line; const total = lineLengthMeters(line); // Short OSM fragments cannot safely lose both ends. Keep their source // geometry intact and let the junction diagnostic surface the ambiguity. if (startCutback + endCutback >= total - 0.5) return line; const result = []; let traversed = 0; const start = pointAlongLine(line, startCutback); const end = pointAlongLine(line, total - endCutback); result.push(start); for (let index = 1; index < line.length - 1; index += 1) { traversed += distanceMeters(line[index - 1], line[index]); if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]); } result.push(end); return result; } 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 angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); } 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 = []; for (let index = 0; index <= segments; index += 1) { const angle = index / segments * Math.PI * 2; ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin)); } return ring; } function roadRing(line, width) { if (line.length < 2 || !Number.isFinite(width)) return null; const origin = line[0]; const points = line.map((point) => project(point, origin)); const left = []; const right = []; const half = width / 2; for (let i = 0; i < points.length; i += 1) { const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)]; const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy); if (length < 0.01) return null; const nx = -dy / length * half; const ny = dx / length * half; left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin)); right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin)); } const ring = [...left, ...right.reverse(), left[0]]; return ring.every((point) => point.every(Number.isFinite)) ? ring : null; } function sidewalkRing(line, innerOffset, outerOffset, side) { const inner = offsetLine(line, innerOffset * side); const outer = offsetLine(line, outerOffset * side); if (!inner || !outer) return null; const ring = [...inner, ...outer.reverse(), inner[0]]; return ring.every((point) => point.every(Number.isFinite)) ? ring : null; } function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; } function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; } function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; } function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; } function parseTags(body) { const tags = {}; for (const match of body.matchAll(/]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; } function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; } function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; } function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); } module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };