'use strict'; const fs = require('fs'); const path = require('path'); const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows'); const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction'); 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 CROSSWALK_JUNCTION_INSET_METERS = 1.5; const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4; const CENTER_LINE_DASH_LENGTH_METERS = 2; const CENTER_LINE_DASH_GAP_METERS = 2; const CENTER_LINE_WIDTH_METERS = 0.25; const CENTER_LINE_SOLID_OVERLAP_METERS = 0.04; const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1; const CENTER_LINE_COLORS = new Set(['yellow', 'white']); const CENTER_LINE_PATTERNS = new Set(['dashed', 'solid']); const CONNECTOR_BOUNDARY_TOLERANCE_METERS = 0.05; // A lane centerline is drawn as a hairline, so probe the crossing with a narrow // band. Using the full lane width would clip the line metres early. const LANE_CENTERLINE_PROBE_WIDTH_METERS = 0.12; const JUNCTION_CURVE_SEGMENTS = 8; 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'))); } // An override points at an id derived from OSM. Editing the source can retire // that id — a way deleted, or split differently so `segment/6` no longer // exists — which leaves the entry pointing at nothing. That is stale data, not // a malformed override, so callers that merely consume overrides can ask to // skip them and keep going. Callers that *save* overrides still use the default // strict mode: writing a reference that cannot resolve is a real error. function staleOverrideTarget(item, sets) { if (!sets.roadIds) return null; if (item.kind === 'road' && typeof item.roadId === 'string' && !sets.roadIds.has(item.roadId)) return item.roadId; if (item.kind === 'lane-separator-style' && typeof item.roadId === 'string' && !sets.roadIds.has(item.roadId)) return item.roadId; if (item.kind === 'edge-line-style' && typeof item.roadId === 'string' && !sets.directionalRoadIds.has(item.roadId)) return item.roadId; if (item.kind === 'center-line-style' && typeof item.segmentId === 'string' && !sets.segmentIds.has(item.segmentId)) return item.segmentId; if ( item.kind === 'junction-connection' && typeof item.fromEndpointId === 'string' && typeof item.toEndpointId === 'string' && (!sets.endpointIds.has(item.fromEndpointId) || !sets.endpointIds.has(item.toEndpointId)) ) return `${item.fromEndpointId} → ${item.toEndpointId}`; if ( item.kind === 'lane-connection' && typeof item.fromLaneId === 'string' && typeof item.toLaneId === 'string' && (!sets.laneIds.has(item.fromLaneId) || !sets.laneIds.has(item.toLaneId)) ) return `${item.fromLaneId} → ${item.toLaneId}`; return null; } function validateOverrides(value, model, options = {}) { 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 directionalRoadIds = model ? new Set(model.roads.map((road) => road.id)) : 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; const sets = { roadIds, directionalRoadIds, endpointIds, laneIds, segmentIds }; const kept = []; const stale = []; 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 (options.skipStaleTargets) { const target = staleOverrideTarget(item, sets); if (target) { stale.push({ id: item.id, kind: item.kind, target }); continue; } } 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) || (item.double !== undefined && typeof item.double !== 'boolean') || (item.double && (item.color !== 'yellow' || item.pattern !== 'solid')) || (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 if (item.kind === 'edge-line-style') { if ( typeof item.roadId !== 'string' || (directionalRoadIds && !directionalRoadIds.has(item.roadId)) || !['left', 'right'].includes(item.side) || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) ) throw new Error('Invalid edge line style override.'); } else throw new Error(`Unsupported override kind: ${item.kind}`); kept.push(item); } return { schema: OVERRIDE_SCHEMA, overrides: kept, stale }; } 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, })); } // A single physical intersection is often mapped as several nodes joined by // short links: a dual carriageway crossing, a slip lane, a staggered junction. // Each node then compiles its own surface and the shared area between them is // left as ordinary road, which is what produces the width jumps and stray // medians around those nodes. Report the clusters so they can be configured as // `complex-junction-v1`. Detection is advisory only — it never enables a // template or changes geometry, because flipping a junction between the // ordinary and complex paths silently on an OSM edit would be unpredictable. const COMPLEX_CANDIDATE_MAX_LINK_METERS = 30; const COMPLEX_CANDIDATE_MIN_NODES = 2; // Short links chain transitively, so a run of closely spaced junctions along // one street unions into a single 'cluster' that is really a corridor. A real // intersection stays compact, so bound the cluster by its own diameter: the // surveyed 珠山湖大道 cluster spans 24.6 m on its four mapped nodes and 40.7 m // once the neighbouring service-road junction is folded in. const COMPLEX_CANDIDATE_MAX_DIAMETER_METERS = 45; function detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics) { const configured = new Set( (options.junctionTemplates?.clusters || []).flatMap((cluster) => (cluster.nodeIds || []).map(String)), ); const segmentsByNode = new Map(); for (const endpoint of model.endpoints) { const key = String(endpoint.nodeId); if (!segmentsByNode.has(key)) segmentsByNode.set(key, new Set()); segmentsByNode.get(key).add(endpoint.roadId.replace(/:(forward|backward)$/, '')); } const junctionNodes = new Set( [...segmentsByNode].filter(([, segments]) => segments.size >= 3).map(([nodeId]) => nodeId), ); const parent = new Map(); const find = (id) => { if (!parent.has(id)) parent.set(id, id); while (parent.get(id) !== id) { parent.set(id, parent.get(parent.get(id))); id = parent.get(id); } return id; }; const union = (first, second) => { const a = find(first); const b = find(second); if (a !== b) parent.set(a, b); }; const links = new Map(); const seenSegments = new Set(); for (const road of model.roads) { if (seenSegments.has(road.segmentId)) continue; seenSegments.add(road.segmentId); const start = String(road.sourceNodeIds[0]); const end = String(road.sourceNodeIds.at(-1)); if (start === end || !junctionNodes.has(start) || !junctionNodes.has(end)) continue; const length = lineLengthMeters(road.centerline); if (length > COMPLEX_CANDIDATE_MAX_LINK_METERS) continue; union(start, end); links.set(road.segmentId, { start, end, length }); } const clusters = new Map(); for (const nodeId of parent.keys()) { const root = find(nodeId); if (!clusters.has(root)) clusters.set(root, []); clusters.get(root).push(nodeId); } for (const nodeIds of clusters.values()) { if (nodeIds.length < COMPLEX_CANDIDATE_MIN_NODES) continue; if (nodeIds.some((nodeId) => configured.has(nodeId))) continue; const points = nodeIds.map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean); if (points.length !== nodeIds.length) continue; const center = points.reduce( (sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0], ); const spreadMeters = Math.max(...points.map((point) => distanceMeters(center, point))); let diameterMeters = 0; for (let first = 0; first < points.length; first += 1) { for (let second = first + 1; second < points.length; second += 1) diameterMeters = Math.max(diameterMeters, distanceMeters(points[first], points[second])); } if (diameterMeters > COMPLEX_CANDIDATE_MAX_DIAMETER_METERS) continue; const inner = [...links.values()].filter((link) => nodeIds.includes(link.start) && nodeIds.includes(link.end)); const widths = nodeIds.flatMap((nodeId) => (junctionPlans.get(nodeId)?.approaches || []).map((approach) => approach.widthMeters), ); const widestApproach = widths.length ? Math.max(...widths) : 0; // Enough core to cover every member node plus the widest approach's half // width, with a little slack. A starting point for tuning, not a result. const suggestedCoreRadius = Math.min(80, Math.max(12, Math.round(spreadMeters + widestApproach / 2 + 4))); diagnostics.push({ ...diagnostic( 'info', `junction-cluster-candidate:${nodeIds.slice().sort().join('+')}`, nodeIds, 'complex-junction-candidate', `检测到 ${nodeIds.length} 个路口节点由 ${inner.length} 条短路段(最长 ${Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10} 米)相连,可能是同一个物理路口。当前按独立路口编译;如需合并请在 nativeRoad.junctionTemplates.clusters 中配置。`, center, ), suggestedCluster: { template: 'complex-junction-v1', nodeIds: nodeIds.slice().sort(), nodeCount: nodeIds.length, spreadMeters: Math.round(spreadMeters * 10) / 10, diameterMeters: Math.round(diameterMeters * 10) / 10, longestLinkMeters: Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10, widestApproachMeters: Math.round(widestApproach * 100) / 100, coreRadiusMeters: suggestedCoreRadius, }, }); } } function compileGeometry(model, overrides = { overrides: [] }, options = {}) { const diagnostics = [...model.diagnostics]; const junctionPlans = compileJunctionPlans(model, options, diagnostics); detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics); const features = []; const activeClusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []; const clusterByNode = new Map( activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])), ); const complexClusterCenters = new Map( activeClusters .filter((cluster) => cluster.template === 'complex-junction-v1') .map((cluster) => { const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean); const center = points.length ? points.reduce( (sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0], ) : null; return [cluster.id, center]; }), ); const emittedSegments = new Set(); const generatedComplexSidewalks = []; const generatedComplexCrosswalks = []; const generatedComplexStopLines = []; 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 startCluster = clusterByNode.get(String(road.sourceNodeIds[0])); const endCluster = clusterByNode.get(String(road.sourceNodeIds.at(-1))); if ( startCluster?.template === 'complex-junction-v1' && endCluster?.template === 'complex-junction-v1' && startCluster.id === endCluster.id ) continue; const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); // The approach surface stops at the junction cutback. The junction layer // owns the intervening rounded corners; leaving approaches untrimmed // would cover that outline with rectangular road ends in Blender/Cesium. const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1))); if (cluster?.template === 'complex-junction-v1') { const center = complexClusterCenters.get(cluster.id); const length = lineLengthMeters(road.centerline); const farEndpoint = cluster.nodeIds.map(String).includes(String(road.sourceNodeIds[0])) ? road.centerline.at(-1) : road.centerline[0]; const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius; if (center && length < outerRadius + 8 && distanceMeters(farEndpoint, center) < outerRadius) continue; } const line = cluster?.template === 'complex-junction-v1' ? trimLineAtComplexCluster( road.centerline, road.sourceNodeIds, junctionPlans, cluster, complexClusterCenters.get(cluster.id), ) : trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); const ring = roadRing(line, 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, cluster_id: cluster?.template === 'complex-junction-v1' ? cluster.id : null, 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] }, }); } for (const [nodeId, plan] of junctionPlans) { if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue; if (!plan.template) continue; for (const approach of plan.approaches) { const transition = templateApproachRing(approach, plan); if (!transition) { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-approach-fallback', '进口道路长度不足以生成规整过渡面,已保留该进口的 native 直筒道路。', plan.node, ), ); continue; } features.push({ type: 'Feature', properties: { native_id: `junction-approach:${plan.template}:node/${nodeId}:${approach.segmentId}`, osm_node_id: nodeId, segment_id: approach.segmentId, directional_road_ids: approach.roadIds.join(','), width_m: approach.widthMeters, approach_width_m: Math.round(approach.widthMeters * plan.approachWidthMultiplier * 10) / 10, approach_length_m: Math.round(transition.lengthMeters * 10) / 10, template: plan.template, provenance: 'native-road-junction-approach-template/v1', }, geometry: { type: 'Polygon', coordinates: [transition.ring] }, }); } } for (const cluster of activeClusters) { if (cluster.template !== 'complex-junction-v1') continue; const generated = buildComplexJunctionGeometry(model, cluster, { junctionPlans, diagnostic, distanceMeters, lineLengthMeters, pointAlongLine, offsetCoordinate, headingAtEndpoint, headingVector, circleRing, }); features.push(...generated.features); if (generated.crosswalks) generatedComplexCrosswalks.push(...generated.crosswalks); if (generated.stopLines) generatedComplexStopLines.push(...generated.stopLines); if (generated.islands) generatedComplexSidewalks.push(...generated.islands); diagnostics.push(...generated.diagnostics); } const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, { crosswalks: generatedComplexCrosswalks, stopLines: generatedComplexStopLines, }); const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans, options, features); const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans); const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines], }; const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options); const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, allControls, options); markings.separators.push( ...compileComplexLaneSeparators(lanes.features, [...allControls.crosswalks, ...allControls.stopLines]), ); markings.directionArrows.push(...compileComplexPreviewArrows(lanes.features, generatedComplexStopLines)); const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans, options); // Complex crosswalks are generated from native approach tangents; do not // synthesize side strips that can be mistaken for crosswalks. sidewalks.push(...generatedComplexSidewalks); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans); const junctionFeatures = compileJunctionSurfaces( model, junctionPlans, connectorResult.features, connectorResult.movements, diagnostics, options, ); 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, ...generatedComplexCrosswalks] }, vehicleStopLines: { type: 'FeatureCollection', features: [...controls.stopLines, ...generatedComplexStopLines] }, connectors: { type: 'FeatureCollection', features: connectorResult.features }, movements: connectorResult.movements, diagnostics, }; } function compileComplexPreviewArrows(features, stopLines) { const result = []; for (const feature of features) { if ( !feature.properties?.cluster_preview || !feature.properties.incoming || feature.properties.maneuver === 'outbound' ) continue; const line = feature.geometry?.coordinates || []; if (line.length < 2) continue; const stopLine = stopLines.find((candidate) => candidate.properties?.road_id === feature.properties.road_id); if (!stopLine) continue; const stopRing = stopLine.geometry?.coordinates?.[0]; if (!stopRing || stopRing.length < 4) continue; const stopPoints = stopRing.slice(0, -1); const stopCenter = [ stopPoints.reduce((sum, point) => sum + point[0], 0) / stopPoints.length, stopPoints.reduce((sum, point) => sum + point[1], 0) / stopPoints.length, ]; const placement = distanceMeters(line.at(-1), stopCenter) + 8; const placementInfo = pointAndAxisAlongLine(line, Math.max(0, lineLengthMeters(line) - placement)); if (!placementInfo) continue; const rings = arrowRingsAt(feature.properties.maneuver, placementInfo.point, placementInfo.axis); for (let part = 0; part < rings.length; part += 1) result.push({ type: 'Feature', properties: { native_id: `${feature.properties.native_id}:arrow:${part}`, road_id: feature.properties.road_id, lane_id: feature.properties.native_id, cluster_id: feature.properties.cluster_id, cluster_preview: true, maneuver: feature.properties.maneuver, travel_heading_deg: headingDegrees(line[0], line.at(-1)), placement_distance_from_stop_meters: 8, provenance: 'native-road-complex-preview-arrow/v2-stop-anchored', }, geometry: { type: 'Polygon', coordinates: [rings[part]] }, }); } return result; } function compileComplexLaneSeparators(features, controls = []) { const groups = new Map(); for (const feature of features) { if (!feature.properties?.cluster_preview || !feature.properties.road_id) continue; if (!groups.has(feature.properties.road_id)) groups.set(feature.properties.road_id, []); groups.get(feature.properties.road_id).push(feature); } const result = []; for (const [roadId, lanes] of groups) { lanes.sort((first, second) => first.properties.lane_index - second.properties.lane_index); for (let index = 1; index < lanes.length; index += 1) { const left = lanes[index - 1].geometry.coordinates; const right = lanes[index].geometry.coordinates; if (left.length !== right.length) continue; const line = left.map((point, pointIndex) => [ (point[0] + right[pointIndex][0]) / 2, (point[1] + right[pointIndex][1]) / 2, ]); const visibleLine = trimLineBeforeFirstControl(line, controls, 0.12); const ring = visibleLine ? roadRing(visibleLine, 0.12) : null; if (!ring) continue; result.push({ type: 'Feature', properties: { native_id: `complex-lane-separator:${roadId}:${index}-${index + 1}`, road_id: roadId, cluster_id: lanes[0].properties.cluster_id, left_lane_index: index, right_lane_index: index + 1, color: 'white', pattern: 'solid', effective_style: 'white-solid', provenance: 'native-road-complex-lane-separator/v1', }, geometry: { type: 'Polygon', coordinates: [ring] }, }); } } return result; } function trimLineBeforeFirstControl(line, controls, width) { const total = lineLengthMeters(line); const step = 0.25; for (let distance = step; distance <= total; distance += step) { const placement = pointAndAxisAlongLine(line, Math.min(total, distance - step / 2)); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], step, width, 0); if (!ringsOverlapControl([ring], controls)) continue; let cutoff = Math.max(0, distance - step - 0.12); while (cutoff > 0.5) { const candidate = roadRing([line[0], pointAlongLine(line, cutoff)], width); if (candidate && !ringsOverlapControl([candidate], controls)) return [line[0], pointAlongLine(line, cutoff)]; cutoff -= 0.25; } return null; } return line; } // `trimLineBeforeFirstControl` always keeps the head of the line, so the caller // must hand it a line that already runs from the road towards the junction. // Lane centerlines arrive in either orientation (a preview lane runs inward // from the outer radius, an outgoing road lane runs outward from the cluster // node), so orient by radius first and restore the original order afterwards. function trimLaneOutsideControls(line, controls, width, center) { if (!controls.length || !center || !Array.isArray(line) || line.length < 2) return line; const outwardFirst = distanceMeters(line[0], center) >= distanceMeters(line.at(-1), center); const oriented = outwardFirst ? line : [...line].reverse(); const trimmed = trimLineBeforeFirstControl(oriented, controls, width); if (!trimmed) return null; return outwardFirst ? trimmed : [...trimmed].reverse(); } // Reference identity is not reliable here: the reversed path rebuilds the array // even when nothing was cut. Compare travelled length instead. function laneWasClipped(original, visible) { return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - 0.01; } function compileEdgeLines(model, overrides, junctionPlans, options = {}, roadSurfaces = []) { const features = []; const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []; const clusterByNode = new Map( clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])), ); const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)])); // Roads swallowed by a complex cluster never get a surface. Deriving their // edge from the centerline anyway paints a curb across bare ground, so take // the surfaces actually emitted as the authority on what can be outlined. const surfaced = new Set( roadSurfaces .flatMap((feature) => String(feature.properties?.directional_road_ids || '').split(',')) .filter(Boolean), ); for (const road of model.roads) { if (surfaced.size && !surfaced.has(road.id)) continue; // The road surface is one polygon per segment, centred on this centerline // and spanning the sum of both directions. Deriving the edge from a single // direction's width puts it half a carriageway inside the asphalt. const directions = model.roads.filter((item) => item.segmentId === road.segmentId); const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); const bidirectional = directions.length > 1; // Match the surface's trim exactly. A cluster road is cut at the cluster // boundary, not at the ordinary junction cutback; using the cutback here // runs the edge line out past the asphalt it is supposed to outline. const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1))); const line = cluster?.template === 'complex-junction-v1' ? trimLineAtComplexCluster( road.centerline, road.sourceNodeIds, junctionPlans, cluster, clusterCenters.get(cluster.id), ) : trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); // On a two-way segment, the inner edge is the road centre boundary and is // owned by center_lines. Emit only each directional carriageway's outer // edge; emitting both sides makes the layer look like a second centreline. const offsets = bidirectional ? [-1] : [-1, 1]; for (const offset of offsets) { const side = offset < 0 ? 'right' : 'left'; const style = edgeLineStyle(overrides, road.id, side); const centerline = offsetLine(line, (offset * totalWidth) / 2); if (!centerline) continue; if (style.pattern === 'solid') { const ring = roadRing(centerline, 0.12); if (ring) features.push(edgeLineFeature(road, side, style, ring)); continue; } for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) { const placement = pointAndAxisAlongLine(centerline, distance); if (!placement) continue; features.push( edgeLineFeature( road, side, style, rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, 0.12, 0), part, ), ); } } } return features; } function edgeLineFeature(road, side, style, ring, part = null) { return { type: 'Feature', properties: { native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ''}`, road_id: road.id, side, osm_way_ids: road.osmWayIds.join(','), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: 'native-road-edge-line/v1', }, geometry: { type: 'Polygon', coordinates: [ring] }, }; } function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics, options = {}) { 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 cluster = clusterForRoad(forward, options) || clusterForRoad(backward, options); const internalCluster = cluster && roadInternalToCluster(forward, cluster); const line = cluster ? trimLineAtComplexCluster( forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, clusterCenter(cluster, junctionPlans), ) : 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 visibleLine = trimLineBeforeFirstControl(line, controlFeatures, CENTER_LINE_WIDTH_METERS); const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0; if (!visibleLine || visibleLength < CENTER_LINE_DASH_LENGTH_METERS) continue; 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 <= visibleLength; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1 ) { const placement = pointAndAxisAlongLine(visibleLine, start + markLength / 2); if (!placement) continue; 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, ); for (const offset of style.double ? [-0.16, 0.16] : [0]) { const ring = rectangleAt( placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, offset, ); features.push({ type: 'Feature', properties: { native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), 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, double: Boolean(style.double), effective_style: `${style.double ? 'double-' : ''}${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, double: Boolean(override.double) } : { color: 'yellow', pattern: 'dashed', double: false }; } function edgeLineStyle(overrides, roadId, side) { const value = overrides.overrides.find( (item) => item.kind === 'edge-line-style' && item.roadId === roadId && item.side === side, ); return value ? { color: value.color, pattern: value.pattern } : { color: 'white', pattern: 'solid' }; } function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new Map()) { 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 junctionInsetMeters = approach ? crossingJunctionInset(approach, junctionPlans) : 0; const controlCenter = offsetByMeters(crossing.coordinate, crosswalkCandidate.placement.axis, junctionInsetMeters); 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(controlCenter, axis, across, 3, 0.45, -2.25 + index * 0.9), { junctionInsetMeters }, ), ); 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, controlCenter); const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0]; const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1]; const laneCenterAtCrossing = offsetByMeters(controlCenter, 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, 0.45, 0), { junctionInsetMeters }, ), ); } return { crosswalks, stopLines }; } function crossingJunctionInset(candidate, junctionPlans) { const junctionNodeId = candidate.road.sourceNodeIds.at(-1); const plan = junctionPlans.get(junctionNodeId); if (!plan) return 0; const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS); return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance)); } 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, placement = {}) { 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', junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100, provenance: stop ? 'native-road-stop-line/v1' : 'native-road-crosswalk/v1', }, geometry: { type: 'Polygon', coordinates: [ring] }, }; } function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls, options = {}) { const separators = []; const directionArrows = []; const turnArrows = []; const controlFeatures = [...controls.crosswalks, ...controls.stopLines]; for (const road of model.roads) { const cluster = clusterForRoad(road, options); const internalCluster = cluster && roadInternalToCluster(road, cluster); const roadLanes = (lanes.markingByRoadId || 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, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), 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 visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, 0.12); const ring = visibleLine ? roadRing(visibleLine, 0.12) : null; if (ring) separators.push({ type: 'Feature', properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: 'Polygon', coordinates: [ring] }, }); } else { const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, 0.12); const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0; for (let distance = 1, part = 1; visibleLine && distance + 1 <= visibleLength; distance += 4, part += 1) { const placement = pointAndAxisAlongLine(visibleLine, distance); if (!placement) continue; const ring = rectangleAt( placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, 0.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).map((feature) => ({ ...feature, properties: { ...feature.properties, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), }, })), ); 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, cluster_id: cluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), 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 clusterForRoad(road, options) { const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []; return ( clusters.find( (cluster) => cluster.template === 'complex-junction-v1' && road.sourceNodeIds.some((nodeId) => cluster.nodeIds.map(String).includes(String(nodeId))), ) || null ); } function roadInternalToCluster(road, cluster) { const nodeIds = new Set(cluster.nodeIds.map(String)); return nodeIds.has(String(road.sourceNodeIds[0])) && nodeIds.has(String(road.sourceNodeIds.at(-1))); } function clusterCenter(cluster, junctionPlans) { const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean); return points.length ? points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]) : null; } function angularDistance(first, second) { return Math.abs(((first - second + 180) % 360) - 180); } 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, options = {}) { 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)], ]; const cluster = clusterForRoad(forward, options) || (backward ? clusterForRoad(backward, options) : null); const center = cluster ? clusterCenter(cluster, junctionPlans) : null; for (const [side, enabled] of sides) { if (!enabled) continue; const centerline = cluster ? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, center) : 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, cluster_id: cluster?.id || null, 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, options)); return features; } function compileSidewalkCorners(model, junctionPlans, options = {}) { const result = []; for (const [nodeId, plan] of junctionPlans) { if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue; 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, outwardHeading: heading, 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 continuation = isStraightSidewalkContinuation(first, second); if (first.sourceWayKey === second.sourceWayKey && !continuation) continue; // A split-through road has two approaches at this node. Its pedestrian // strip is a direct continuation, not a curb corner. Treating it as a // curve creates the oversized outer lobe seen at T junctions. const ring = continuation ? [first.curb, first.outer, second.outer, second.curb, first.curb] : roundedSidewalkCorner(plan.node, first, second); if (hasSelfIntersection(ring)) continue; if (continuation && 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: continuation ? 'continuation' : 'corner', width_m: DEFAULT_SIDEWALK_WIDTH_METERS, provenance: continuation ? 'native-road-sidewalk-continuation/v1' : 'native-road-sidewalk-corner/v1', }, geometry: { type: 'Polygon', coordinates: [ring] }, }); } } return result; } function roundedSidewalkCorner(node, first, second) { // Keep the established vehicle curb geometry, then derive the outer edge // from it. Independent Bezier curves drift apart and leave asphalt exposed // between the junction and pedestrian layers. const curbForward = roundedCorner(node, first.curb, second.curb, first.outwardHeading, second.outwardHeading) || [ first.curb, second.curb, ]; // Construct the outside edge from the same tangent-support rule. A linear // point-by-point offset changes the curvature and makes the two boundaries // visibly disagree at the middle of the corner. const outerForward = roundedCorner(node, first.outer, second.outer, first.outwardHeading, second.outwardHeading) || offsetCornerArc(curbForward, first.curb, first.outer, second.curb, second.outer); const curbArc = [...curbForward].reverse(); return [ first.curb, first.outer, ...outerForward.slice(1, -1), second.outer, second.curb, ...curbArc.slice(1, -1), first.curb, ]; } function offsetCornerArc(curbArc, firstCurb, firstOuter, secondCurb, secondOuter) { return curbArc.map((point, index) => { const ratio = curbArc.length === 1 ? 0 : index / (curbArc.length - 1); const firstOffset = [firstOuter[0] - firstCurb[0], firstOuter[1] - firstCurb[1]]; const secondOffset = [secondOuter[0] - secondCurb[0], secondOuter[1] - secondCurb[1]]; return [ point[0] + firstOffset[0] + (secondOffset[0] - firstOffset[0]) * ratio, point[1] + firstOffset[1] + (secondOffset[1] - firstOffset[1]) * ratio, ]; }); } function samePhysicalSide(first, second) { const radians = ((first.normalDegrees - second.normalDegrees) * Math.PI) / 180; return Math.cos(radians) >= 0.98; } function isStraightSidewalkContinuation(first, second) { if (first.sourceWayKey !== second.sourceWayKey || !samePhysicalSide(first, second)) return false; const radians = ((first.outwardHeading - second.outwardHeading) * Math.PI) / 180; return Math.cos(radians) <= -0.98; } function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) { const vertices = ring.slice(0, -1); const center = vertices.reduce( (sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [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(); for (const feature of junctionFeatures) { if (feature.properties.osm_node_ids) for (const nodeId of String(feature.properties.osm_node_ids).split(',')) junctionByNode.set(nodeId, feature); else if (feature.properties.osm_node_id) junctionByNode.set(feature.properties.osm_node_id, feature); } for (const connector of connectors) { const junction = junctionByNode.get(connector.properties.node_id); if (!junction) continue; if (junction.properties.kind === 'cluster') continue; const ring = junction.geometry.coordinates[0]; if ( !connector.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS), ) ) { 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 pointInOrNearPolygon(point, ring, toleranceMeters) { return ( pointInPolygon(point, ring) || ring.slice(1).some((end, index) => distancePointToSegmentMeters(point, ring[index], end) <= toleranceMeters) ); } function distancePointToSegmentMeters(point, start, end) { const localPoint = project(point, start); const localEnd = project(end, start); const lengthSquared = localEnd[0] ** 2 + localEnd[1] ** 2; if (lengthSquared < 0.0001) return Math.hypot(...localPoint); const ratio = Math.max(0, Math.min(1, (localPoint[0] * localEnd[0] + localPoint[1] * localEnd[1]) / lengthSquared)); return Math.hypot(localPoint[0] - localEnd[0] * ratio, localPoint[1] - localEnd[1] * ratio); } 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 ); } // `complexControls` carries the crosswalks and stop bars the complex-junction // templates already emitted. Ordinary controls cannot be passed here: they are // placed *from* these lane centerlines, so only the template-generated ones // exist this early. function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {}, complexControls = {}) { const features = []; const controlFeatures = [...(complexControls.crosswalks || []), ...(complexControls.stopLines || [])]; const byRoadId = new Map(); const markingByRoadId = new Map(); const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []; const clusterByNode = new Map( clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])), ); const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)])); for (const road of model.roads) { const lanes = []; const markingLanes = []; 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); const boundaryCluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1))); const internalCluster = boundaryCluster && roadInternalToCluster(road, boundaryCluster); const clippedRoadLine = boundaryCluster?.template === 'complex-junction-v1' ? trimLineAtComplexCluster( road.centerline, road.sourceNodeIds, junctionPlans, boundaryCluster, clusterCenters.get(boundaryCluster.id), ) : trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); // 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); const publishedCoordinates = offsetLine(clippedRoadLine, offset); if (!coordinates || !publishedCoordinates) { 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); // Only the published geometry stops at the crossing. `coordinates` stays // whole because connectors are derived from it; a lane that ends at the // stop bar would otherwise break every turn path through the junction. const visibleCoordinates = boundaryCluster?.template === 'complex-junction-v1' ? trimLaneOutsideControls( publishedCoordinates, controlFeatures, LANE_CENTERLINE_PROBE_WIDTH_METERS, clusterCenters.get(boundaryCluster.id), ) : publishedCoordinates; if (!visibleCoordinates) { diagnostics.push( diagnostic( 'warning', road.id, road.osmWayIds, 'lane-centerline-fully-inside-control', '该车道中心线整体落在斑马线或停止线内,已按未裁剪几何发布。', publishedCoordinates[0], ), ); } // Lane markings are laid out along this line. Feeding it the clipped // geometry is what keeps separators and arrows from being painted *past* // a crossing: control avoidance only stops them landing *on* one. markingLanes.push({ ...lane, coordinates: visibleCoordinates || publishedCoordinates }); features.push({ type: 'Feature', properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, cluster_id: boundaryCluster?.id || null, cluster_internal: Boolean(internalCluster), cluster_preview_hidden: Boolean(internalCluster), cluster_boundary_clipped: Boolean(boundaryCluster && !internalCluster), control_clipped: laneWasClipped(publishedCoordinates, visibleCoordinates), source: 'native-road-lane-centerline/v3-control-clipped', }, geometry: { type: 'LineString', coordinates: visibleCoordinates || publishedCoordinates }, }); } byRoadId.set(road.id, lanes); markingByRoadId.set(road.id, markingLanes); } for (const cluster of clusters) { const clusterNodes = new Set(cluster.nodeIds.map(String)); const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean); const compositeCenter = clusterCoordinates.length ? clusterCoordinates.reduce( (sum, point) => [ sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length, ], [0, 0], ) : null; const corridors = []; for (const [nodeId, plan] of junctionPlans) { if (!clusterNodes.has(String(nodeId))) continue; for (const approach of plan.approaches) { const end = approach.line.at(-1); if ( [...clusterNodes].some( (candidate) => candidate !== String(nodeId) && distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3, ) ) continue; const heading = ((headingAtEndpoint(approach.line) + 180) % 360) - 180; corridors.push({ nodeId, heading, approach, plan }); } } for (const corridor of corridors) { const approach = corridor.approach; const plan = corridor.plan; const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius; const length = Math.min( distanceAlongLineToRadius(approach.line, compositeCenter, outerRadius), lineLengthMeters(approach.line), ); if (length < 12) continue; const line = approach.line; const outer = pointAlongLine(line, Math.max(0, length)); const inner = pointAlongLine( line, Math.min(Math.max(3, Number(cluster.coreRadiusMeters || 28) * 0.14), Math.max(3, length - 8)), ); const corridorRoads = approach.roadIds .map((roadId) => model.roads.find((road) => road.id === roadId)) .filter(Boolean); const incoming = corridorRoads.some((road) => String(road.sourceNodeIds.at(-1)) === String(corridor.nodeId)); const count = Math.max( 1, corridorRoads.reduce((sum, road) => sum + road.laneCount, 0), ); const laneWidth = approach.widthMeters / count; const axis = project(inner, outer); const total = Math.hypot(...axis); if (!total) continue; const normalized = [axis[0] / total, axis[1] / total]; const across = [-normalized[1], normalized[0]]; for (let index = 0; index < count; index += 1) { const offset = approach.widthMeters / 2 - laneWidth * (index + 0.5); const start = unproject([across[0] * offset, across[1] * offset], outer); const end = unproject([across[0] * offset, across[1] * offset], inner); const maneuver = incoming ? (index === 0 ? 'left' : index === count - 1 ? 'right' : 'through') : 'outbound'; // Preview lanes are laid out radially from the outer radius inwards, so // an untrimmed one runs straight over the arm crossing. Stop it at the // first control: incoming lanes land on the stop bar, outgoing lanes on // the far edge of the crossing. const visible = trimLaneOutsideControls( [start, end], controlFeatures, LANE_CENTERLINE_PROBE_WIDTH_METERS, compositeCenter, ); features.push({ type: 'Feature', properties: { native_id: `cluster-approach-lane:${cluster.id}:${approach.segmentId}:${index + 1}`, road_id: corridorRoads[0]?.id || null, cluster_id: cluster.id, cluster_preview: true, incoming, lane_index: index + 1, maneuver, control_clipped: laneWasClipped([start, end], visible), source: 'native-road-junction-cluster-lane/v4-control-clipped', }, geometry: { type: 'LineString', coordinates: visible || [start, end] }, }); } } } return { features, byRoadId, markingByRoadId }; } function cubicTurnCurve(start, end, startHeading, endHeading, radius, turn, center) { const reach = turn === 'right' ? Math.max(5, radius * 0.75) : Math.max(9, radius * 1.35); const first = offsetCoordinate(start, startHeading, reach); const second = offsetCoordinate(end, endHeading, reach); const points = []; for (let index = 0; index <= 18; index += 1) { const t = index / 18; const inverse = 1 - t; points.push([ inverse ** 3 * start[0] + 3 * inverse ** 2 * t * first[0] + 3 * inverse * t ** 2 * second[0] + t ** 3 * end[0], inverse ** 3 * start[1] + 3 * inverse ** 2 * t * first[1] + 3 * inverse * t ** 2 * second[1] + t ** 3 * end[1], ]); } return points.every((point) => point.every(Number.isFinite)) ? points : [start, center, end]; } function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) { 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 plan = junctionPlans.get(connection.nodeId); // Cross intersections retain the earlier center-node curve while T junctions // use lane tangents so their through movement does not bow toward the stem. const coordinates = plan?.segmentIds.size === 4 ? quadraticCurve(from, endpointCoordinate(model, connection.fromEndpointId), to, 12) : connectorCurve(defaultFromLane.coordinates, defaultToLane.coordinates, turn); const length = lineLengthMeters(coordinates); const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`; const provenance = override ? `override:${override.id}` : connection.provenance; const connectorId = `connector:${id}`; const geometryStatus = length < 0.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 < 0.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; } const cluster = plan?.clusterId || null; features.push({ type: 'Feature', properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, cluster_id: cluster, cluster_internal: Boolean(cluster), 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 connectorCurve(incoming, outgoing, turn) { const start = incoming.at(-1); const end = outgoing[0]; if (turn === 'through') return lineCurve(start, end, 12); const incomingHeading = headingDegrees(incoming.at(-2), start); const outgoingHeading = headingDegrees(end, outgoing[1]); const chord = distanceMeters(start, end); const incomingSpan = distanceMeters(incoming.at(-2), start); const outgoingSpan = distanceMeters(end, outgoing[1]); const tangentIntersection = intersectTangentRays(start, end, incomingHeading, outgoingHeading); const fallbackDistance = Math.min(8, Math.max(0.75, Math.min(chord * 0.42, incomingSpan * 0.8, outgoingSpan * 0.8))); const firstDistance = tangentIntersection && tangentIntersection.incoming >= 0 ? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(0.75, incomingSpan * 2.4))) / 3 : fallbackDistance; const secondDistance = tangentIntersection && tangentIntersection.outgoing >= 0 ? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(0.75, outgoingSpan * 2.4))) / 3 : fallbackDistance; const firstControl = offsetCoordinate(start, incomingHeading, firstDistance); const secondControl = offsetCoordinate(end, outgoingHeading + 180, secondDistance); return cubicBezier(start, firstControl, secondControl, end, 12); } function intersectTangentRays(start, end, incomingHeading, outgoingHeading) { const incoming = headingVector(incomingHeading); const outgoing = headingVector(outgoingHeading); const delta = project(end, start); const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]; if (Math.abs(cross) < 1e-6) return null; return { incoming: (delta[0] * outgoing[1] - delta[1] * outgoing[0]) / cross, outgoing: (delta[0] * incoming[1] - delta[1] * incoming[0]) / cross, }; } function lineCurve(start, end, segments) { return Array.from({ length: segments + 1 }, (_, index) => interpolate(start, end, index / segments)); } function cubicBezier(a, firstControl, secondControl, b, segments) { const result = []; for (let index = 0; index <= segments; index += 1) { const t = index / segments; const u = 1 - t; result.push([ u ** 3 * a[0] + 3 * u * u * t * firstControl[0] + 3 * u * t * t * secondControl[0] + t ** 3 * b[0], u ** 3 * a[1] + 3 * u * u * t * firstControl[1] + 3 * u * t * t * secondControl[1] + t ** 3 * b[1], ]); } return result; } function quadraticCurve(a, control, b, segments) { 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, options = {}) { const result = []; const complexClusters = new Set( (options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []) .filter((cluster) => cluster.template === 'complex-junction-v1') .map((cluster) => cluster.id), ); for (const [nodeId, plan] of junctionPlans) { if (plan.clusterId && complexClusters.has(plan.clusterId)) continue; 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 = plan.boundaryMode || 'approach-envelope'; if ( hasSelfIntersection(ring) || !junctionConnectors.every((feature) => feature.geometry.coordinates.every((point) => pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS), ), ) ) { 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, template: plan.template || null, template_reference: plan.templateReference || null, 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: plan.template ? 'junction-cross-template/v1' : '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, ), ); if (plan.boundaryFallbacks) diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-rounded-corner-fallback', '部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。', node, ), ); if (!plan.clusterId) diagnostics.push( diagnostic( 'info', `junction:node/${nodeId}`, [nodeId], 'ordinary-junction-surface', '已按道路截面与转向路径生成普通路口面。', node, ), ); } for (const cluster of options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []) { if (cluster.template === 'complex-junction-v1') continue; const clusterNodes = new Set(cluster.nodeIds.map(String)); const members = result.filter((feature) => clusterNodes.has(String(feature.properties.osm_node_id))); if (members.length < 2) continue; const points = []; const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean); const clusterCenter = clusterCoordinates.reduce( (sum, point) => [sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length], [0, 0], ); for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(clusterCenter, index * 45, 12)); for (const [nodeId, plan] of junctionPlans) { if (!clusterNodes.has(String(nodeId))) continue; for (const approach of plan.approaches) { const end = approach.line.at(-1); if ( [...clusterNodes].some( (candidate) => candidate !== String(nodeId) && distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3, ) ) continue; const cutback = pointAlongLine( approach.line, Math.min(plan.cutbackMeters, Math.max(12, cluster.approachLengthMeters * 0.5)), ); const heading = headingAtEndpoint(approach.line); const half = approach.widthMeters / 2; points.push(offsetCoordinate(cutback, heading + 90, half), offsetCoordinate(cutback, heading - 90, half)); } const node = plan.node; for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(node, index * 45, 9)); } const hull = convexHull(points); if (hull.length < 3) continue; const ring = roundedHull(hull, 0.22); const memberIds = new Set(members.map((feature) => feature.properties.native_id)); for (let index = result.length - 1; index >= 0; index -= 1) if (memberIds.has(result[index].properties.native_id)) result.splice(index, 1); result.push({ type: 'Feature', properties: { native_id: `junction-cluster:${cluster.id}`, osm_node_ids: [...clusterNodes].join(','), kind: 'cluster', template: cluster.template, boundary_mode: 'cluster-import-core', center: clusterCenter, member_count: members.length, movement_count: movements.filter((movement) => clusterNodes.has(String(movement.nodeId))).length, connector_count: connectors.filter((feature) => clusterNodes.has(String(feature.properties.node_id))).length, surface_area_m2: Math.round(polygonAreaMeters(ring) * 10) / 10, rule: 'junction-cluster-template/v2', }, geometry: { type: 'Polygon', coordinates: [[...ring, ring[0]]] }, }); diagnostics.push( diagnostic( 'info', `junction-cluster:${cluster.id}`, [...clusterNodes], 'junction-cluster-core-applied', '已按外部进口截面和簇节点核心生成受限复合路口面。', ring[0], ), ); } return result; } function activeComplexCluster(options, clusterId) { return Boolean( clusterId && (options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []).some( (cluster) => cluster.id === clusterId && cluster.template === 'complex-junction-v1', ), ); } function compileJunctionPlans(model, options = {}, diagnostics = []) { 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(); const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []; const clusterByNode = new Map( clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])), ); 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; // Rounded curb corners need enough approach length to retain the full // turning envelope after the corner is cut toward the junction. const baseCutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4; const node = endpoints[0].coordinate; const template = junctionTemplateFor(nodeId, segmentIds, options.junctionTemplates, diagnostics, node); const cutbackMeters = baseCutbackMeters * (template?.cutbackMultiplier || 1); const boundary = junctionBoundary( approaches, node, cutbackMeters, template?.cornerRadiusMultiplier || 1, template?.approachWidthMultiplier || 1, ); if (boundary.points.length < 3) continue; const cluster = clusterByNode.get(String(nodeId)); plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks, template: template?.template || null, templateReference: template?.referenceFile || null, approachWidthMultiplier: template?.approachWidthMultiplier || 1, approachLengthMeters: template?.approachLengthMeters || 0, clusterId: cluster?.id || null, }); } 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), }; }); } // `roadRing` builds the trimmed road's end edge from the direction at the // cutback point, not at the node. Taking the node-side heading here instead // leaves the two edges non-parallel whenever the way bends inside the cutback, // and the junction surface then opens a wedge against the road it should meet. function headingAtCutback(line, cutbackMeters) { const point = pointAlongLine(line, cutbackMeters); if (!point) return null; let traversed = 0; for (let index = 1; index < line.length; index += 1) { traversed += distanceMeters(line[index - 1], line[index]); // The first original vertex past the cutback is what the trimmed line // carries as its second point, so match that pair exactly. if (traversed > cutbackMeters + 1e-9) return headingDegrees(point, line[index]); } return headingAtEndpoint(line); } // A dual carriageway reaches a node as two approaches on almost the same // bearing. Their four side points interleave once sorted by angle, and because // consecutive points then belong to different segments a rounded corner gets // inserted between each interleaved pair. Those curves dive back toward the // node and render as arch-shaped holes in the junction. Merge such approaches // into one face and keep only its outermost edges. const PARALLEL_APPROACH_DEGREES = 25; function signedHeadingDelta(value) { return ((((value + 180) % 360) + 360) % 360) - 180; } function mergeParallelApproachPoints(points, node) { const groups = []; for (const item of points) { const group = groups.find( (candidate) => Math.abs(signedHeadingDelta(candidate.heading - item.outwardHeading)) < PARALLEL_APPROACH_DEGREES, ); if (group) { group.points.push(item); continue; } groups.push({ heading: item.outwardHeading, points: [item] }); } return groups.flatMap((group) => { const segments = [...new Set(group.points.map((item) => item.segmentId))]; if (segments.length < 2) return group.points; // Order across the face by bearing measured from the group's own heading, // so the comparison never straddles the +/-180 discontinuity. const sorted = [...group.points].sort( (first, second) => signedHeadingDelta(headingDegrees(node, first.point) - group.heading) - signedHeadingDelta(headingDegrees(node, second.point) - group.heading), ); const merged = segments.sort().join('+'); return [sorted[0], sorted.at(-1)].map((item) => ({ ...item, segmentId: merged, sourceWayKey: merged })); }); } function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) { const points = []; for (const approach of approaches) { const cutback = pointAlongLine(approach.line, cutbackMeters); if (!cutback) continue; const heading = headingAtCutback(approach.line, cutbackMeters) ?? headingAtEndpoint(approach.line); const half = (approach.widthMeters * approachWidthMultiplier) / 2; points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading, }); points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading, }); } const faces = mergeParallelApproachPoints(points, node); const ordered = faces.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point)); if (ordered.length < 3) return { points: [], mode: 'approach-envelope' }; const boundary = []; let rounded = 0; let fallbacks = 0; for (let index = 0; index < ordered.length; index += 1) { const first = ordered[index]; const second = ordered[(index + 1) % ordered.length]; boundary.push(first.point); // One physical OSM way is often split at an intersection node. Its two // opposite approaches share a continuous road edge; rounding that edge // bends the far side of a T junction and exposes junction asphalt beyond // the pedestrian strip. if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue; const curve = roundedCorner( node, first.point, second.point, first.outwardHeading, second.outwardHeading, cornerRadiusMultiplier, ); if (!curve) { fallbacks += 1; continue; } boundary.push(...curve.slice(1, -1)); rounded += 1; } return { points: boundary, mode: rounded ? 'rounded-approach-envelope' : 'approach-envelope', fallbacks }; } function templateApproachRing(approach, plan) { const innerDistance = plan.cutbackMeters; const availableLength = lineLengthMeters(approach.line) - innerDistance - 0.5; const lengthMeters = Math.min(plan.approachLengthMeters, availableLength); if (lengthMeters < 10) return null; const outerDistance = innerDistance + lengthMeters; const inner = pointAlongLine(approach.line, innerDistance); const outer = pointAlongLine(approach.line, outerDistance); const heading = headingAtEndpoint(approach.line); const innerHalf = (approach.widthMeters * plan.approachWidthMultiplier) / 2; const outerHalf = approach.widthMeters / 2; const ring = [ offsetCoordinate(outer, heading + 90, outerHalf), offsetCoordinate(inner, heading + 90, innerHalf), offsetCoordinate(inner, heading - 90, innerHalf), offsetCoordinate(outer, heading - 90, outerHalf), offsetCoordinate(outer, heading + 90, outerHalf), ]; return ring.every((point) => point.every(Number.isFinite)) ? { ring, lengthMeters } : null; } function roundedHull(hull, factor) { const result = []; for (let index = 0; index < hull.length; index += 1) { const previous = hull[(index - 1 + hull.length) % hull.length]; const current = hull[index]; const next = hull[(index + 1) % hull.length]; const entry = interpolate(previous, current, factor); const exit = interpolate(current, next, factor); result.push(entry); const curve = quadraticCurve(entry, current, exit, 4); result.push(...curve.slice(1, -1)); result.push(exit); } return result; } function isStraightJunctionEdge(first, second) { if (first.sourceWayKey !== second.sourceWayKey) return false; const radians = ((first.outwardHeading - second.outwardHeading) * Math.PI) / 180; return Math.cos(radians) <= -0.98; } function roundedCorner(node, first, second, firstHeading, secondHeading, radiusMultiplier = 1) { const origin = node; const a = project(first, origin); const b = project(second, origin); const chord = Math.hypot(a[0] - b[0], a[1] - b[1]); if (chord < 0.5 || !Number.isFinite(firstHeading) || !Number.isFinite(secondHeading)) return null; const firstDirection = headingVector(firstHeading); const secondDirection = headingVector(secondHeading); const intersection = lineIntersection(a, firstDirection, b, secondDirection); if (!intersection) return null; const controlDistance = Math.hypot(...intersection); const endpointDistance = Math.max(Math.hypot(...a), Math.hypot(...b)); // Adjacent approach edge tangents should meet in the corner between the // node and the cutback. Reject near-parallel or remote intersections rather // than publishing a huge/self-crossing curve. if (controlDistance < 0.01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null; const scaledIntersection = [intersection[0] * radiusMultiplier, intersection[1] * radiusMultiplier]; const control = unproject(scaledIntersection, origin); return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS); } function junctionTemplateFor(nodeId, segmentIds, configured, diagnostics, node) { if (!configured?.enabled) return null; const entry = (configured.references || []).find((item) => String(item.nodeId) === String(nodeId)); if (!entry) return null; if (segmentIds.size !== 4) { diagnostics.push( diagnostic( 'info', `junction:node/${nodeId}`, [nodeId], 'junction-template-topology-skip', 'cross 模板只应用于四臂路口,当前路口保留 native 几何。', node, ), ); return null; } if (entry.template !== 'cross-v1') { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-unsupported', '路口模板名称不受支持,已回退 native 几何。', node, ), ); return null; } const multiplier = Number(entry.cornerRadiusMultiplier ?? 1); if (!Number.isFinite(multiplier) || multiplier < 0.75 || multiplier > 1.25) { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-invalid-parameter', 'cross 模板圆角参数必须在 0.75 到 1.25 之间,已回退 native 几何。', node, ), ); return null; } const cutbackMultiplier = Number(entry.cutbackMultiplier ?? 1); if (!Number.isFinite(cutbackMultiplier) || cutbackMultiplier < 1 || cutbackMultiplier > 1.35) { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-invalid-parameter', 'cross 模板进口过渡参数必须在 1 到 1.35 之间,已回退 native 几何。', node, ), ); return null; } const approachWidthMultiplier = Number(entry.approachWidthMultiplier ?? 1); if (!Number.isFinite(approachWidthMultiplier) || approachWidthMultiplier < 1 || approachWidthMultiplier > 1.8) { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-invalid-parameter', 'cross 模板进口宽度参数必须在 1 到 1.8 之间,已回退 native 几何。', node, ), ); return null; } const approachLengthMeters = Number(entry.approachLengthMeters ?? 24); if (!Number.isFinite(approachLengthMeters) || approachLengthMeters < 10 || approachLengthMeters > 50) { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-invalid-parameter', 'cross 模板进口过渡长度必须在 10 到 50 米之间,已回退 native 几何。', node, ), ); return null; } if (entry.referenceFile && !fs.existsSync(entry.referenceFile)) { diagnostics.push( diagnostic( 'warning', `junction:node/${nodeId}`, [nodeId], 'junction-template-reference-missing', '路口参考文件不存在,已回退 native 几何。', node, ), ); return null; } diagnostics.push( diagnostic( 'info', `junction:node/${nodeId}`, [nodeId], 'junction-template-applied', '已按 cross-v1 模板规整路口面;道路、车道、连接器和控制设施保持 native 结果。', node, ), ); return { template: entry.template, referenceFile: entry.referenceFile || null, cornerRadiusMultiplier: multiplier, cutbackMultiplier, approachWidthMultiplier, approachLengthMeters, }; } function headingVector(degrees) { const radians = (degrees * Math.PI) / 180; return [Math.sin(radians), Math.cos(radians)]; } function lineIntersection(firstPoint, firstDirection, secondPoint, secondDirection) { const cross = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0]; if (Math.abs(cross) < 1e-4) return null; const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; const firstDistance = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / cross; return [firstPoint[0] + firstDirection[0] * firstDistance, firstPoint[1] + firstDirection[1] * firstDistance]; } 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 trimLineAtComplexCluster(line, sourceNodeIds, junctionPlans, cluster, center) { if (!center || line.length < 2) return trimLineAtJunctions(line, sourceNodeIds, junctionPlans); const boundaryRadius = complexJunctionMetrics(cluster).approachOuterRadius; const startInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds[0])); const endInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds.at(-1))); const available = lineLengthMeters(line); const startDistance = startInCluster ? distanceAlongLineToRadius(line, center, boundaryRadius) : 0; const endDistance = endInCluster ? distanceAlongLineToRadius([...line].reverse(), center, boundaryRadius) : 0; const startCutback = startDistance > 0 && available > startDistance + 1 ? startDistance : 0; const endCutback = endDistance > 0 && available > endDistance + 1 ? endDistance : 0; if (!startCutback && !endCutback) return line; return trimLineRange(line, startCutback, endCutback); } function distanceAlongLineToRadius(line, center, radius) { if (!center || line.length < 2) return 0; const heading = headingAtEndpoint(line); const vector = project(line[0], center); const radians = (heading * Math.PI) / 180; const startRadius = vector[0] * Math.sin(radians) + vector[1] * Math.cos(radians); return Math.max(0, radius - startRadius); } function trimLineRange(line, startCutback, endCutback) { const total = lineLengthMeters(line); if (startCutback + endCutback >= total - 0.5) return line; const result = [pointAlongLine(line, startCutback)]; let traversed = 0; 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(pointAlongLine(line, total - endCutback)); 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; } // Offsetting every vertex by a fixed distance along its averaged normal has no // miter limit: where the centerline turns and the neighbouring segment is short // — typically the stub left after junction trimming — consecutive offset points // swap order and the edge doubles back. The ring then self-intersects and the // folded lobe renders as a hole. Drop the reversed vertices so each offset // edge keeps travelling the same way as the centerline segment it follows. function removeOffsetFolds(offset, centerline) { let kept = offset.map((point, index) => ({ point, index })); for (let guard = 0; guard < offset.length && kept.length > 2; guard += 1) { let removed = false; for (let position = 0; position < kept.length - 1; position += 1) { const from = kept[position]; const to = kept[position + 1]; const alongCenter = [ centerline[to.index][0] - centerline[from.index][0], centerline[to.index][1] - centerline[from.index][1], ]; const alongOffset = [to.point[0] - from.point[0], to.point[1] - from.point[1]]; if (alongCenter[0] * alongOffset[0] + alongCenter[1] * alongOffset[1] >= 0) continue; // Keep both termini: they are where the surface meets its junctions. kept.splice(position + 1 === kept.length - 1 ? position : position + 1, 1); removed = true; break; } if (!removed) break; } return kept.map((item) => item.point); } 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([points[i][0] + nx, points[i][1] + ny]); right.push([points[i][0] - nx, points[i][1] - ny]); } const leftEdge = removeOffsetFolds(left, points).map((point) => unproject(point, origin)); const rightEdge = removeOffsetFolds(right, points).map((point) => unproject(point, origin)); if (leftEdge.length < 2 || rightEdge.length < 2) return null; const ring = [...leftEdge, ...rightEdge.reverse(), leftEdge[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, };