commit eb859c40d5922d965708d655e42814b85932a455 Author: que01 Date: Tue Aug 25 17:12:58 2026 +0800 refactor: move road compiler core into package diff --git a/package.json b/package.json new file mode 100644 index 0000000..336e583 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "@osm-asset/road-compiler", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "test": "node test/index.js" + } +} diff --git a/src/compile/compiler.js b/src/compile/compiler.js new file mode 100644 index 0000000..008f728 --- /dev/null +++ b/src/compile/compiler.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./native-road"); +const { loadOrGenerate, runtime } = require("../native-traffic-signals"); + +function compileInput(input) { + validateInput(input); + const area = { + id: input.areaId, + input: input.osmFile, + nativeRoad: input.options, + outputs: { + nativeRoadOverrides: input.overridesFile, + nativeTrafficSignals: input.trafficSignalsFile, + nativeRoadDir: input.outDir, + pipelineDir: input.stagingDir, + geojsonDir: input.comparisonDir || null, + }, + }; + const overrides = loadOverrides(area.outputs.nativeRoadOverrides); + const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides); + // Editing the source OSM retires the ids some overrides point at. Those + // entries can no longer match anything, so drop them with a diagnostic rather + // than aborting the whole compile — otherwise every OSM edit blocks the + // pipeline until the file is hand-pruned, one error message at a time. + const validated = validateOverrides(overrides, model, { skipStaleTargets: true }); + for (const item of validated.stale) console.warn(`[warning] 忽略失效的 override(目标已不存在):${item.id} -> ${item.target}`); + fs.mkdirSync(area.outputs.pipelineDir, { recursive: true }); + const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines, junctionTemplates: area.nativeRoad.junctionTemplates }); + compiled.diagnostics.push(...validated.stale.map((item) => ({ + id: `diagnostic:stale-override:${item.id}`, + severity: "warning", + subjectId: item.id, + sourceIds: [], + rule: "stale-override-target", + message: `该设置指向的 ${item.kind} 目标 ${item.target} 已不存在(OSM 改动后 id 失效),本次编译已忽略。可在工作台重新设置,或从 native-road-overrides.json 中删除。`, + geometry: null, + }))); + const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface); + const signalRuntime = runtime(signalDocument); + // Persist validation normalization, including one-time legacy heading migration. + writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument); + const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-")); + try { + const result = { + schema: "native-road-compiled/v1", + areaId: area.id, + source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals }, + model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections }, + movements: compiled.movements, + trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length }, + diagnostics: compiled.diagnostics, + layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" }, + }; + const comparison = compareOsm2Streets(area, result.model, compiled); + writeJsonAtomic(path.join(staging, "compiled.json"), result); + writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics }); + writeJsonAtomic(path.join(staging, "comparison.json"), comparison); + writeJsonAtomic(path.join(staging, "traffic-signal-assemblies.json"), signalDocument.assemblies); + writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime); + writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface); + writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines); + writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface); + writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface); + writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines); + writeJsonAtomic(path.join(staging, "layers", "lane_separators.geojson"), compiled.laneSeparators); + writeJsonAtomic(path.join(staging, "layers", "center_lines.geojson"), compiled.centerLines); + writeJsonAtomic(path.join(staging, "layers", "direction_arrows.geojson"), compiled.directionArrows); + writeJsonAtomic(path.join(staging, "layers", "turn_arrows.geojson"), compiled.turnArrows); + writeJsonAtomic(path.join(staging, "layers", "crosswalks.geojson"), compiled.crosswalks); + writeJsonAtomic(path.join(staging, "layers", "vehicle_stop_lines.geojson"), compiled.vehicleStopLines); + writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors); + fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true }); + fs.renameSync(staging, area.outputs.nativeRoadDir); + return { area, result, comparison }; + } catch (error) { + fs.rmSync(staging, { recursive: true, force: true }); + throw error; + } +} + +function compareOsm2Streets(area, model, compiled) { + const source = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null; + let featureCount = null; + if (source && fs.existsSync(source)) { + const collection = JSON.parse(fs.readFileSync(source, "utf8")); + featureCount = Array.isArray(collection.features) ? collection.features.length : null; + } + const diagnosticsBySeverity = {}; + const diagnosticsByRule = {}; + for (const item of compiled.diagnostics) { + diagnosticsBySeverity[item.severity] = (diagnosticsBySeverity[item.severity] || 0) + 1; + diagnosticsByRule[item.rule] = (diagnosticsByRule[item.rule] || 0) + 1; + } + const dangling = compiled.diagnostics.filter((item) => item.rule === "unconnected-interior-road-end"); + const junctions = compiled.intersectionSurface.features; + const fallbackJunctions = junctions.filter((feature) => feature.properties.boundary_mode === "connector-convex-fallback"); + return { + schema: "native-road-comparison/v2", + nativeRoadCount: model.roads.length, + nativeRoadSurfaceFeatures: compiled.roadSurface.features.length, + nativeSidewalkSurfaceFeatures: compiled.sidewalkSurface.features.length, + nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length, + nativeApproachEnvelopeJunctions: junctions.length - fallbackJunctions.length, + nativeFallbackJunctions: fallbackJunctions.length, + nativeMaxJunctionExpansionRatio: junctions.reduce((maximum, feature) => Math.max(maximum, Number(feature.properties.expansion_ratio) || 0), 0), + nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length, + nativeLaneSeparatorFeatures: compiled.laneSeparators.features.length, + nativeCenterLineFeatures: compiled.centerLines.features.length, + nativeDirectionArrowFeatures: compiled.directionArrows.features.length, + nativeTurnArrowFeatures: compiled.turnArrows.features.length, + nativeCrosswalkFeatures: compiled.crosswalks.features.length, + nativeVehicleStopLineFeatures: compiled.vehicleStopLines.features.length, + nativeConnectorFeatures: compiled.connectors.features.length, + nativeMovementCount: compiled.movements.length, + nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length, + nativeConnectionCount: model.connections.length, + unconnectedInteriorRoadEnds: dangling.length, + unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length, + diagnosticsBySeverity, + diagnosticsByRule, + osm2streetsRoadSurfaceFeatures: featureCount, + osm2streetsAvailable: featureCount !== null, + note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review.", + }; +} + +function validateInput(input) { + if (!input || typeof input !== "object") throw new Error("RoadCompilerInput must be an object"); + for (const key of ["areaId", "osmFile", "outDir", "stagingDir", "overridesFile", "trafficSignalsFile"]) { + if (typeof input[key] !== "string" || input[key].trim() === "") throw new Error(`RoadCompilerInput.${key} must be a non-empty string`); + } + if (!input.options || typeof input.options !== "object") throw new Error("RoadCompilerInput.options must be an object"); + if (typeof input.options.edgeLines !== "boolean") throw new Error("RoadCompilerInput.options.edgeLines must be a boolean"); + if (!input.options.junctionTemplates || typeof input.options.junctionTemplates !== "object") throw new Error("RoadCompilerInput.options.junctionTemplates must be an object"); +} + +module.exports = { compileInput, validateInput }; diff --git a/src/compile/complex-junction.js b/src/compile/complex-junction.js new file mode 100644 index 0000000..5f41891 --- /dev/null +++ b/src/compile/complex-junction.js @@ -0,0 +1,474 @@ +"use strict"; + +const fs = require("fs"); +const { convertGeoJson, boundsOf } = require("../reference/gaode"); +const metricsCache = new WeakMap(); +const CORNER_FILLET_SEGMENTS = 12; +// Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band +// lines up with the straight strips it joins. +const SIDEWALK_WIDTH_METERS = 2; +// The straight strips are trimmed against the cluster boundary using the road +// centerline, so they stop a little beyond the carriageway end. Run the corner +// past that end and let the two overlap rather than chase an exact seam. +const SIDEWALK_CORNER_OVERRUN_METERS = 6; + +function buildComplexJunctionGeometry(model, cluster, helpers) { + const nodeIds = new Set(cluster.nodeIds.map(String)); + const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean); + if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] }; + const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]); + const approaches = []; + const carriageways = []; + for (const [nodeId, plan] of helpers.junctionPlans) { + if (!nodeIds.has(String(nodeId))) continue; + for (const approach of plan.approaches) { + const endpoint = approach.line.at(-1); + if (nodes.some((node) => node !== plan.node && helpers.distanceMeters(endpoint, node) < 4)) continue; + const heading = helpers.headingAtEndpoint(approach.line); + const length = helpers.lineLengthMeters(approach.line); + carriageways.push({ nodeId, approach, plan, heading, length }); + if (approaches.some((item) => Math.abs(normalizeHeading(item.heading - heading)) < 20)) continue; + approaches.push({ nodeId, approach, plan, heading, length }); + } + } + if (approaches.length < 3) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-approaches", "复合路口无法识别足够的外部进口。", center)] }; + const { calibration, coreRadius } = complexJunctionMetrics(cluster); + const sorted = [...approaches].sort((a, b) => a.heading - b.heading); + const arms = sorted.map((representative) => ({ + representative, + heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)), + members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20), + })); + const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius; + const boundaryParts = []; + const crosswalks = []; + const stopLines = []; + const islands = []; + const armCrosswalkRadius = coreRadius * .68; + for (const item of carriageways) { + const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers); + const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers); + const outerHalf = item.approach.widthMeters / 2; + const innerHalf = outerHalf; + boundaryParts.push({ item, outer, inner, outerHalf, innerHalf }); + const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId)); + if (incomingRoad) { + // Keep the stop bar just outside the road crosswalk. The previous fixed + // core-radius offset placed it nearly ten metres beyond the crossing. + const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers); + const ring = [ + helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24), + helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24), + helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24), + helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24), + helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24), + ]; + stopLines.push({ type: "Feature", properties: { native_id: `complex-stop-line:${cluster.id}:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-stop-line", road_id: incomingRoad.id, node_id: item.nodeId, direction: item.heading, provenance: "native-road-complex-junction-stop-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); + } + } + // The four support lines provide a common corner frame, but each long + // crossing remains clipped to its OSM-derived road envelope. Corner islands + // fill the remaining frame gaps; crossings must never do that job. + for (const arm of arms) { + const envelope = armEnvelopeAtRadius(arm, armCrosswalkRadius, center, helpers); + if (!envelope) continue; + arm.crosswalkFrame = { + center: envelope.center, + groupDepth: 3.4, + supportHeading: normalizeHeading(arm.heading + 90), + envelopeWidthMeters: envelope.widthMeters, + }; + } + const frameCorners = arms.map((arm, index) => { + const next = arms[(index + 1) % arms.length]; + const delta = positiveHeadingDelta(arm.heading, next.heading); + if (delta < 45 || delta > 135 || !arm.crosswalkFrame || !next.crosswalkFrame) return null; + return supportLineIntersection(arm.crosswalkFrame, next.crosswalkFrame, center); + }); + for (let index = 0; index < arms.length; index += 1) { + const arm = arms[index]; + if (!arm.crosswalkFrame) continue; + const item = arm.representative; + const roadEdgeInset = .35; + const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2); + const endpoints = [ + helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2), + helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2), + ]; + const groupDepth = arm.crosswalkFrame.groupDepth; + const stripeWidth = .42; + const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1); + const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0; + arm.crosswalkFrame.center = midpoint(...endpoints); + arm.crosswalkFrame.endpoints = endpoints; + arm.crosswalkFrame.spanMeters = usableSpan; + arm.crosswalkFrame.roadEdgeInsetMeters = roadEdgeInset; + for (let stripe = 0; stripe < stripeCount; stripe += 1) { + const along = stripeWidth / 2 + stripe * stripeSpacing; + const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along); + const ring = [ + helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2), + helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2), + helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2), + helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2), + helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2), + ]; + crosswalks.push({ type: "Feature", properties: { native_id: `complex-crosswalk:${cluster.id}:${item.nodeId}:${item.approach.segmentId}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-crosswalk", crossing_node_id: item.nodeId, road_id: item.approach.roadIds[0], direction: arm.heading, radial_distance_m: armCrosswalkRadius, span_m: usableSpan, road_envelope_span_m: arm.crosswalkFrame.envelopeWidthMeters, road_edge_inset_m: roadEdgeInset, group_depth_m: groupDepth, stripe_width_m: stripeWidth, stripe_spacing_m: stripeSpacing, frame_center: arm.crosswalkFrame.center, frame_support_heading: arm.crosswalkFrame.supportHeading, provenance: "native-road-complex-junction-crosswalk/v6-road-clipped" }, geometry: { type: "Polygon", coordinates: [ring] } }); + } + } + // The four arm groups are the sides of one pedestrian frame. Each diagonal + // group is anchored at the intersection of its adjacent side support lines, + // so all eight groups stay one composition when the OSM arms are skewed. + for (let index = 0; index < arms.length; index += 1) { + const first = arms[index]; + const second = arms[(index + 1) % arms.length]; + const delta = positiveHeadingDelta(first.heading, second.heading); + if (delta < 45 || delta > 135) continue; + if (!first.crosswalkFrame || !second.crosswalkFrame) continue; + const bisector = normalizeHeading(first.heading + delta / 2); + const frameCorner = frameCorners[index]; + if (!frameCorner) continue; + const cornerStripeSpacing = .62; + const cornerStripeWidth = .4; + const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2; + const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0]; + const outerEdgeAtCorner = (arm) => { + const endpoint = endpointForCorner(arm); + return [arm.heading, arm.heading + 180] + .map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2)) + .sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0]; + }; + const islandBaseGap = .05; + const islandApexOffset = 1.5; + const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap)); + const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset); + const islandCrossingClearance = .2; + const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth; + const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector))); + const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector); + const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset); + const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24); + islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } }); + + let cornerCrossingHalfSpan = .4; + for (let stripe = 0; stripe < 6; stripe += 1) { + const stripeOffset = (stripe - 2.5) * cornerStripeSpacing; + const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset); + const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector); + const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers); + if (!curbPair) continue; + const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2); + cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan); + const stripePair = [ + helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan), + helpers.offsetCoordinate(stripeCenter, bisector + 90, halfSpan), + ]; + const ring = [ + helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2), + helpers.offsetCoordinate(stripePair[1], bisector, -cornerStripeWidth / 2), + helpers.offsetCoordinate(stripePair[1], bisector, cornerStripeWidth / 2), + helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2), + helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2), + ]; + crosswalks.push({ type: "Feature", properties: { native_id: `complex-corner-crosswalk:${cluster.id}:${index + 1}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-corner-crosswalk", corner_index: index + 1, direction: bisector, radial_distance_m: stripeRadius, frame_corner: frameCorner, from_heading: first.heading, to_heading: second.heading, provenance: "native-road-complex-junction-corner-crosswalk/v3" }, geometry: { type: "Polygon", coordinates: [ring] } }); + } + } + // Each carriageway ends in its own rectangle, so adjacent arms meet at a + // sharp notch instead of a curb. A real corner is one tangent-continuous + // sweep between the two outermost road edges, so fit a fixed-radius fillet + // into the wedge those edges form and fill the sector behind it. + const cornerFills = []; + const sidewalkCorners = []; + const cornerDiagnostics = []; + const cornerRadius = Math.max(4, Math.min(25, Number(cluster.cornerRadiusMeters) || 12)); + for (let index = 0; index < arms.length; index += 1) { + const first = arms[index]; + const second = arms[(index + 1) % arms.length]; + const delta = positiveHeadingDelta(first.heading, second.heading); + if (delta < 45 || delta > 135) continue; + const bisector = normalizeHeading(first.heading + delta / 2); + const edges = [first, second].map((arm) => cornerEdgeAt(arm, coreRadius + 6, bisector, center, helpers)); + if (!edges.every(Boolean)) continue; + const apex = rayIntersection(edges[0], edges[1], center); + const apexReach = apex ? directionalProjectionMeters(center, apex, bisector) : null; + // The wedge apex has to sit ahead of the core and inside the arm handoff; + // outside that band the two edges are near parallel and any fillet fitted + // to them would sweep across the carriageways instead of the corner. + if (apexReach === null || apexReach < 1 || apexReach > outerRadius) { + cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center)); + continue; + } + // Tangent distance for a circle of `cornerRadius` inscribed in a wedge of + // opening `delta`, clamped so the tangent points stay on the built arms. + const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach)); + const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance)); + const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS); + const ring = [...curve, center, curve[0]]; + if (!ring.every((point) => point.every(Number.isFinite))) continue; + cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); + + // The straight pedestrian strips are trimmed at the cluster boundary, so + // two arms that both carry a footway still meet as two loose ends across + // an empty wedge. Bridge them along the curb the fillet already defines. + // The corner faces clockwise from `first` and counter-clockwise from + // `second`, so each arm must carry the footway on that facing side. + if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue; + const curb = [ + ...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(), + ...curve.slice(1, -1), + ...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers), + ]; + const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers); + const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]]; + if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) { + cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center)); + continue; + } + sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } }); + } + const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second)); + const coreRing = roundedPolygonRing(corePoints, .16); + const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }]; + for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) { + const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)]; + features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } }); + } + // Corner fills come last so they overlay the rectangular carriageway ends + // they are smoothing; they never replace an OSM-derived road surface. + features.push(...cornerFills); + // `coreRadiusMeters` is only consulted when there is no reference geometry. + // Under calibration the radius comes from the reference span, so a configured + // value that silently does nothing has to be reported, not swallowed. + const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5; + const configurationDiagnostics = configuredRadiusIgnored + ? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)] + : []; + return { features, islands: [...islands, ...sidewalkCorners], crosswalks, stopLines, center, approaches, diagnostics: [...cornerDiagnostics, ...configurationDiagnostics, helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], calibration ? "complex-junction-reference-calibrated" : "complex-junction-generated", calibration ? `已使用参考几何校准参数后,由 OSM/native 重新生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。` : `已独立生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。`, center)] }; +} + +function readReferenceCalibration(cluster) { + if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null; + try { + const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8"))); + const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) }); + const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180); + return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 }; + } catch (_) { + return null; + } +} + +function complexJunctionMetrics(cluster) { + if (metricsCache.has(cluster)) return metricsCache.get(cluster); + const calibration = readReferenceCalibration(cluster); + // Without a reference geometry `coreRadiusMeters` is the only size input the + // template has, so honour it literally within the schema's own 12..80 range. + // It used to be scaled by .52 and clamped to 17, which silently capped every + // unreferenced junction at a core far smaller than its own approach envelope + // — a 24 m dual-carriageway arm was being fitted into a 16.6 m core no matter + // what the config asked for. The calibrated branch is unchanged. + const coreRadius = calibration + ? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14)) + : Math.max(12, Math.min(80, Number(cluster.coreRadiusMeters) || 28)); + const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) }; + metricsCache.set(cluster, metrics); + return metrics; +} + +function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; } + +// `arm.heading` points outward from the junction, so the corner clockwise from +// it sits at heading+90 and the one counter-clockwise at heading-90. A road's +// own sidewalk flags are relative to its digitisation direction, so flip them +// whenever the arm runs against that direction. +function armCarriesSidewalk(arm, model, cornerIsClockwise) { + return arm.members.some((member) => member.approach.roadIds + .map((roadId) => model.roads.find((road) => road.id === roadId)) + .filter(Boolean) + .some((road) => { + const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId); + const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft; + const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight; + return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide); + })); +} + +// Walk outward along a wedge edge from its tangent point until the curb reaches +// `targetRadius`, so the corner band overlaps the straight strip it joins. +function edgeRunToRadius(apex, edge, tangentDistance, targetRadius, center, helpers) { + const points = []; + for (let extra = 0; extra <= 40; extra += 2) { + const point = helpers.offsetCoordinate(apex, edge.heading, tangentDistance + extra); + points.push(point); + if (helpers.distanceMeters(point, center) >= targetRadius) break; + } + return points; +} + +// Offset each vertex along the polyline normal that increases distance from the +// junction centre. The curb is star-shaped around that centre, so "farther from +// the centre" is a reliable stand-in for "on the pedestrian side". +function offsetPolylineAwayFromCenter(points, center, meters, helpers) { + return points.map((point, index) => { + const previous = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + const tangent = previous === next ? 0 : bearing(previous, next); + return [tangent + 90, tangent - 90] + .map((heading) => helpers.offsetCoordinate(point, heading, meters)) + .sort((first, second) => helpers.distanceMeters(second, center) - helpers.distanceMeters(first, center))[0]; + }); +} + +function ringSelfIntersects(ring) { + const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + const straddles = (p1, p2, p3, p4) => { + const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2); + const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); + }; + for (let first = 0; first < ring.length - 1; first += 1) { + for (let second = first + 2; second < ring.length - 1; second += 1) { + if (first === 0 && second === ring.length - 2) continue; + if (straddles(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true; + } + } + return false; +} + +function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); } +function signedLateralMeters(origin, point, heading) { + const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180); + const north = (point[1] - origin[1]) * 111320; + const radians = (heading + 90) * Math.PI / 180; + return east * Math.sin(radians) + north * Math.cos(radians); +} +function bearing(first, second) { + const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180); + const north = second[1] - first[1]; + return Math.atan2(east, north) * 180 / Math.PI; +} +function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; } +function averageHeading(headings) { + const vector = headings.reduce((sum, heading) => { + const radians = heading * Math.PI / 180; + return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)]; + }, [0, 0]); + return Math.atan2(vector[0], vector[1]) * 180 / Math.PI; +} +function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; } +function pointOnCarriagewayRadius(item, center, radius, helpers) { + const start = item.approach.line[0]; + const startRadius = directionalProjectionMeters(center, start, item.heading); + return helpers.pointAlongLine(item.approach.line, Math.max(0, Math.min(item.length, radius - startRadius))); +} +function armEnvelopeAtRadius(arm, radius, center, helpers) { + if (!arm.members.length) return null; + const centers = arm.members.map((member) => pointOnCarriagewayRadius(member, center, radius, helpers)); + const reference = centers[0]; + let minimum = Infinity; + let maximum = -Infinity; + centers.forEach((point, index) => { + const lateral = signedLateralMeters(reference, point, arm.heading); + const halfWidth = arm.members[index].approach.widthMeters / 2; + minimum = Math.min(minimum, lateral - halfWidth); + maximum = Math.max(maximum, lateral + halfWidth); + }); + if (!Number.isFinite(minimum) || maximum - minimum < 1) return null; + return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum }; +} +function supportLineIntersection(first, second, origin) { + const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180); + const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320]; + const firstPoint = toLocal(first.center); + const secondPoint = toLocal(second.center); + const direction = (heading) => { + const radians = heading * Math.PI / 180; + return [Math.sin(radians), Math.cos(radians)]; + }; + const firstDirection = direction(first.supportHeading); + const secondDirection = direction(second.supportHeading); + const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0]; + if (Math.abs(denominator) < 1e-6) return null; + const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; + const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator; + const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst]; + return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320]; +} +function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) { + const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)]; + if (!pair.every(Boolean)) return null; + const width = helpers.distanceMeters(pair[0], pair[1]); + const middle = midpoint(pair[0], pair[1]); + const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2); + const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90; + return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)]; +} +function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) { + return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null; +} +function cornerEdgeAt(arm, radius, bisector, center, helpers) { + const candidates = arm.members.flatMap((member) => { + const point = pointOnCarriagewayRadius(member, center, radius, helpers); + const halfWidth = member.approach.widthMeters / 2; + return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading })); + }); + return candidates.sort((first, second) => directionalProjectionMeters(center, second.point, bisector) - directionalProjectionMeters(center, first.point, bisector))[0] || null; +} +function rayIntersection(first, second, origin) { + const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180); + const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320]; + const direction = (heading) => { + const radians = heading * Math.PI / 180; + return [Math.sin(radians), Math.cos(radians)]; + }; + const firstPoint = toLocal(first.point); + const secondPoint = toLocal(second.point); + const firstDirection = direction(first.heading); + const secondDirection = direction(second.heading); + const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0]; + if (Math.abs(denominator) < 1e-4) return null; + const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; + const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator; + const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst]; + if (!local.every(Number.isFinite)) return null; + return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320]; +} +function quadraticCurve(start, control, end, segments) { + const result = []; + for (let index = 0; index <= segments; index += 1) { + const t = index / segments; + const u = 1 - t; + result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]); + } + return result; +} +function directionalProjectionMeters(origin, point, heading) { + const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180); + const north = (point[1] - origin[1]) * 111320; + const radians = heading * Math.PI / 180; + return east * Math.sin(radians) + north * Math.cos(radians); +} +function smoothClosedRing(vertices) { + // Curb-edge candidates can arrive in opposite winding orders when an OSM + // carriageway bends slightly. Sort this local corner only around its own + // centroid before rounding, avoiding a self-crossing safety island while + // keeping the global junction boundary fully OSM-driven. + const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]); + const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0])); + const points = ordered.flatMap((point, index) => { + const next = ordered[(index + 1) % ordered.length]; + return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)]; + }); + return [...points, points[0]]; +} +function roundedPolygonRing(vertices, ratio) { + const points = vertices.flatMap((point, index) => { + const previous = vertices[(index - 1 + vertices.length) % vertices.length]; + const next = vertices[(index + 1) % vertices.length]; + return [interpolateCoordinate(previous, point, 1 - ratio), interpolateCoordinate(point, next, ratio)]; + }); + return [...points, points[0]]; +} +function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; } + +module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics }; diff --git a/src/compile/native-road.js b/src/compile/native-road.js new file mode 100644 index 0000000..692275a --- /dev/null +++ b/src/compile/native-road.js @@ -0,0 +1,1695 @@ +"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 = .25; +const CENTER_LINE_SOLID_OVERLAP_METERS = .04; +const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1; +const CENTER_LINE_COLORS = new Set(["yellow", "white"]); +const CENTER_LINE_PATTERNS = new Set(["dashed", "solid"]); +const CONNECTOR_BOUNDARY_TOLERANCE_METERS = .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 = .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, .12); + const ring = visibleLine ? roadRing(visibleLine, .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 = .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 - .12); + while (cutoff > .5) { + const candidate = roadRing([line[0], pointAlongLine(line, cutoff)], width); + if (candidate && !ringsOverlapControl([candidate], controls)) return [line[0], pointAlongLine(line, cutoff)]; + cutoff -= .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) - .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, .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, .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 ? [-.16, .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, .45, -2.25 + index * .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, .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, .12); + const ring = visibleLine ? roadRing(visibleLine, .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, .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, .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 < .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) * .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 + .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 * .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 < .4 ? "continuous" : length > 80 ? "deferred-too-long" : "connector"; + const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus }; + if (length < .4) { movements.push(movement); continue; } + if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; } + 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(.75, Math.min(chord * .42, incomingSpan * .8, outgoingSpan * .8))); + const firstDistance = tangentIntersection && tangentIntersection.incoming >= 0 ? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(.75, incomingSpan * 2.4))) / 3 : fallbackDistance; + const secondDistance = tangentIntersection && tangentIntersection.outgoing >= 0 ? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(.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 * .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 - .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 < .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 < .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 - .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 }; diff --git a/src/compile/turn-lane-arrows.js b/src/compile/turn-lane-arrows.js new file mode 100644 index 0000000..4230679 --- /dev/null +++ b/src/compile/turn-lane-arrows.js @@ -0,0 +1,502 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { laneCenterline } = require("../geometry/lane-geometry"); + +const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "..", "..", "assets", "lane-icons", "manifest.json"); +const LANE_WIDTH_METERS = 3.2; +const PLACEMENT_DISTANCE_METERS = 9; +const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18; +const SPATIAL_MATCH_MIN_ALIGNMENT = Math.cos(Math.PI / 6); + +// Existing osm2streets lane arrows are approximately 1.4 m across. Keep the +// 25-unit upstream icon at the same on-road scale rather than at screen scale. +const SVG_METERS_PER_UNIT = 0.10; + +function loadManifest(file = ASSET_MANIFEST) { + const manifest = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Array.isArray(manifest.assets)) throw new Error("turn-lane asset manifest has no assets array"); + return manifest; +} + +function supportedAssets(manifest = loadManifest()) { + return new Map(manifest.assets + .filter((asset) => asset.supported === true && asset.tested === true) + .map((asset) => [asset.id, asset])); +} + +function buildCustomTurnLaneArrows(osm, options = {}) { + const enabled = options.enabled === true; + const diagnostics = []; + if (!enabled) return { features: [], diagnostics: [{ reason: "disabled" }] }; + const assets = supportedAssets(options.manifest); + const endpointRoadCounts = roadCountsByNode(osm); + const networkIntersectionNodes = new Set((options.network?.intersections || []) + .flatMap(([, intersection]) => intersection.osm_ids || []).map(Number)); + const features = []; + const ways = [...osm.ways.values()].sort((a, b) => a.id - b.id); + for (const way of ways) { + for (const direction of ["forward", "backward"]) { + const tag = way.tags[`turn:lanes:${direction}`]; + if (!tag) continue; + const laneCount = directionalLaneCount(way, direction); + if (!laneCount) { + diagnostics.push(skip(way, direction, "missing_lane_count")); + continue; + } + const endpoint = endpointGeometry(osm, way, direction, endpointRoadCounts, networkIntersectionNodes); + if (!endpoint) { + diagnostics.push(skip(way, direction, "indeterminate_intersection_endpoint")); + continue; + } + const maneuvers = String(tag).split("|").map((value) => normalizeManeuver(value)); + for (let laneIndex = 0; laneIndex < maneuvers.length; laneIndex += 1) { + const maneuver = maneuvers[laneIndex]; + const asset = assets.get(maneuver); + if (!asset) { + diagnostics.push(skip(way, direction, "unsupported_or_untested_maneuver", { lane_index: laneIndex, maneuver })); + continue; + } + if (laneIndex >= laneCount) { + diagnostics.push(skip(way, direction, "lane_index_exceeds_lane_count", { lane_index: laneIndex, maneuver })); + continue; + } + const resolvedPlacement = lanePlacement(way, direction, laneIndex, endpoint, options.lanePolygons, options.crosswalkStripes, options.stopLines); + if (resolvedPlacement?.blocked) { + diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver })); + continue; + } + const placement = resolvedPlacement || fallbackLanePlacement(endpoint, direction, laneIndex, options.crosswalkStripes, options.stopLines); + if (!placement) { + diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver })); + continue; + } + const parts = templateFor(asset.id, options.manifest); + for (let partIndex = 0; partIndex < parts.length; partIndex += 1) { + features.push(makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, parts[partIndex], placement.center, placement)); + } + } + } + } + return { features, diagnostics }; +} + +function normalizeManeuver(value) { + const parts = String(value || "").split(";").map((part) => part.trim()).filter(Boolean).sort(); + const supported = new Map([ + ["through", "through"], ["left", "left"], ["right", "right"], + ["left;through", "through;left"], ["right;through", "through;right"], + ["left;right;through", "through;left;right"], + ]); + return supported.get(parts.join(";")) || parts.join(";"); +} + +function directionalLaneCount(way, direction) { + const specific = Number(way.tags[`lanes:${direction}`]); + if (Number.isInteger(specific) && specific > 0) return specific; + const total = Number(way.tags.lanes); + if (Number.isInteger(total) && total > 0 && total % 2 === 0 && !isOneway(way)) return total / 2; + if (Number.isInteger(total) && total > 0 && isOneway(way)) return total; + return null; +} + +function roadCountsByNode(osm) { + const out = new Map(); + for (const way of osm.ways.values()) { + if (!way.tags.highway || way.tags.highway === "service") continue; + for (const ref of new Set(way.refs)) out.set(ref, (out.get(ref) || 0) + 1); + } + return out; +} + +function endpointGeometry(osm, way, direction, roadCounts, networkIntersectionNodes) { + const forward = direction === "forward"; + const endpointIndex = forward ? way.refs.length - 1 : 0; + const neighborIndex = forward ? endpointIndex - 1 : 1; + const node = osm.nodes.get(way.refs[endpointIndex]); + const neighbor = osm.nodes.get(way.refs[neighborIndex]); + if (!node || !neighbor) return null; + const networkSaysIntersection = networkIntersectionNodes && networkIntersectionNodes.size > 0 && networkIntersectionNodes.has(node.id); + if (!networkSaysIntersection && (roadCounts.get(node.id) || 0) < 3) return null; + const meters = metersForLat(node.lat); + // For both directions, point from the adjacent road node to the endpoint. + // At a forward endpoint this is the OSM-way direction; at a backward + // endpoint it is the reverse OSM-way direction, i.e. the actual travel + // direction used by turn:lanes:backward. + const raw = [node.lon - neighbor.lon, node.lat - neighbor.lat]; + const axis = normalizeMetersVector(raw, meters); + if (!axis) return null; + return { node, axis, right: [axis[1], -axis[0]], meters }; +} + +function laneCenter(endpoint, direction, laneIndex, meters) { + const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS; + // The local axis always follows travel, so moving back from either endpoint + // places the marking on its approach lane before the intersection. + return addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -PLACEMENT_DISTANCE_METERS, endpoint.right, lateral, meters); +} + +function fallbackLanePlacement(endpoint, direction, laneIndex, crosswalkStripes, stopLines) { + const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS; + for (const distance of [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]) { + const center = addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -distance, endpoint.right, lateral, endpoint.meters); + if (!nearIntersectionMarking(center, endpoint.axis, crosswalkStripes, stopLines, endpoint.meters)) { + return { center, axis: endpoint.axis, right: endpoint.right, meters: endpoint.meters, placementDistance: distance, placementSource: "osm_way_fallback" }; + } + } + return null; +} + +function lanePlacement(way, direction, laneIndex, endpoint, lanePolygons, crosswalkStripes, stopLines) { + if (!Array.isArray(lanePolygons)) return null; + const expectedDirection = direction === "forward" ? "Fwd" : "Back"; + const directionalCandidates = lanePolygons.filter((feature) => + feature.properties?.type === "Driving" && + feature.properties.direction === expectedDirection + ); + let candidates = directionalCandidates.filter((feature) => + (feature.properties.osm_way_ids || []).map(Number).includes(way.id) + ); + let placementSource = "driving_lane_centerline"; + let spatialAnchors = null; + if (!candidates.length) { + const ranked = directionalCandidates + .map((feature) => ({ feature, anchor: spatialLaneAnchor(feature, endpoint) })) + .filter(({ anchor }) => anchor) + .filter(({ anchor }) => anchor.alignment >= SPATIAL_MATCH_MIN_ALIGNMENT && anchor.distance <= SPATIAL_MATCH_MAX_DISTANCE_METERS) + .sort((a, b) => a.anchor.distance - b.anchor.distance || a.anchor.lateral - b.anchor.lateral || Number(a.feature.properties.index) - Number(b.feature.properties.index)); + if (ranked.length) { + // JOSM may split a tagged OSM way into temporary negative IDs. Those IDs + // are absent from osm2streets' rendered polygons, so associate the full + // physical approach by endpoint proximity and road-axis alignment. + candidates = ranked.map(({ feature }) => feature); + placementSource = "spatial_driving_lane_centerline"; + spatialAnchors = new Map(ranked.map(({ feature, anchor }) => [feature, anchor])); + } + } + candidates.sort((a, b) => { + const lateralA = spatialAnchors?.get(a)?.lateral; + const lateralB = spatialAnchors?.get(b)?.lateral; + if (Number.isFinite(lateralA) && Number.isFinite(lateralB) && lateralA !== lateralB) return lateralA - lateralB; + return Number(a.properties.index) - Number(b.properties.index); + }); + const lane = candidates[laneIndex]; + const spatialAnchor = spatialAnchors?.get(lane); + if (spatialAnchor) { + const sampled = placementDistances().map((distance) => ({ + center: sampleCenterlineAwayFromEndpoint(spatialAnchor, distance, endpoint.meters), + distance, + })).find(({ center }) => center && !nearIntersectionMarking(center, spatialAnchor.axis, crosswalkStripes, stopLines, endpoint.meters)); + if (!sampled) return { blocked: true }; + return { center: sampled.center, axis: spatialAnchor.axis, right: [spatialAnchor.axis[1], -spatialAnchor.axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource }; + } + const centerline = laneCenterline(lane); + if (!centerline) return null; + const startsAtEndpoint = direction === "backward"; + const ordered = startsAtEndpoint ? centerline : [...centerline].reverse(); + const sampled = [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42] + .map((distance) => ({ center: samplePolyline(ordered, distance, endpoint.meters), distance })) + .find(({ center }) => center && !nearIntersectionMarking(center, axisForLane(ordered, endpoint.meters), crosswalkStripes, stopLines, endpoint.meters)); + if (!sampled) return { blocked: true }; + const axis = axisForLane(ordered, endpoint.meters); + if (!axis) return null; + return { center: sampled.center, axis, right: [axis[1], -axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource }; +} + +function spatialLaneAnchor(lane, endpoint) { + const centerline = laneCenterline(lane); + if (!centerline) return null; + let best = null; + for (let index = 0; index < centerline.length - 1; index += 1) { + const start = centerline[index]; + const end = centerline[index + 1]; + const point = closestPointOnSegment([endpoint.node.lon, endpoint.node.lat], start, end, endpoint.meters); + const distance = Math.hypot((point[0] - endpoint.node.lon) * endpoint.meters.lon, (point[1] - endpoint.node.lat) * endpoint.meters.lat); + const tangent = normalizeMetersVector(subtractPoint(end, start), endpoint.meters); + if (!tangent || (best && distance >= best.distance)) continue; + const dot = tangent[0] * endpoint.axis[0] + tangent[1] * endpoint.axis[1]; + const axis = dot >= 0 ? tangent : [-tangent[0], -tangent[1]]; + const offset = subtractPoint(point, [endpoint.node.lon, endpoint.node.lat]); + best = { + point, + distance, + axis, + alignment: Math.abs(dot), + lateral: offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat, + centerline, + segmentIndex: index, + }; + } + return best; +} + +function placementDistances() { + return [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]; +} + +function sampleCenterlineAwayFromEndpoint(anchor, distanceMeters, meters) { + const { centerline, segmentIndex } = anchor; + const start = centerline[segmentIndex]; + const end = centerline[segmentIndex + 1]; + const tangent = normalizeMetersVector(subtractPoint(end, start), meters); + if (!tangent) return null; + // Walk away from the junction along the rendered centerline. This preserves + // curved or split lane geometry instead of approximating it with a tangent. + const towardEnd = tangent[0] * anchor.axis[0] + tangent[1] * anchor.axis[1] < 0; + const points = [anchor.point]; + if (towardEnd) { + for (let index = segmentIndex + 1; index < centerline.length; index += 1) points.push(centerline[index]); + } else { + for (let index = segmentIndex; index >= 0; index -= 1) points.push(centerline[index]); + } + return samplePolyline(points, distanceMeters, meters); +} + +function closestPointOnSegment(point, start, end, meters) { + const dx = (end[0] - start[0]) * meters.lon; + const dy = (end[1] - start[1]) * meters.lat; + const px = (point[0] - start[0]) * meters.lon; + const py = (point[1] - start[1]) * meters.lat; + const lengthSquared = dx * dx + dy * dy; + const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0; + return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])]; +} + +function axisForLane(ordered, meters) { + return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters); +} + +function nearIntersectionMarking(center, axis, stripes, stopLines, meters) { + if (!axis) return true; + const right = [axis[1], -axis[0]]; + const samples = []; + for (const forward of [-0.2, 0.5, 1.2, 1.9, 2.2]) { + for (const lateral of [-1.6, -0.8, 0, 0.8, 1.6]) { + samples.push(addMeters(center, axis, forward, right, lateral, meters)); + } + } + return [...(stripes || []), ...(stopLines || [])].some((feature) => samples.some((point) => nearFeature(point, feature, meters))); +} + +function nearFeature(point, feature, meters) { + const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null; + if (!ring?.length) return false; + const xs = ring.map((coordinate) => coordinate[0]); + const ys = ring.map((coordinate) => coordinate[1]); + const clearance = 0.7; + const dx = Math.max((Math.min(...xs) - point[0]) * meters.lon, 0, (point[0] - Math.max(...xs)) * meters.lon); + const dy = Math.max((Math.min(...ys) - point[1]) * meters.lat, 0, (point[1] - Math.max(...ys)) * meters.lat); + return Math.hypot(dx, dy) < clearance; +} + +function subtractPoint([lon, lat], [otherLon, otherLat]) { + return [lon - otherLon, lat - otherLat]; +} + +function samplePolyline(points, distanceMeters, meters) { + let remaining = distanceMeters; + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + const vector = normalizeMetersVector(subtractPoint(end, start), meters); + const length = Math.hypot((end[0] - start[0]) * meters.lon, (end[1] - start[1]) * meters.lat); + if (!vector || !length) continue; + if (remaining <= length) return addMeters(start, vector, remaining, [0, 0], 0, meters); + remaining -= length; + } + return null; +} + +function makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, template, center, endpoint) { + const ring = template.map(([rightMeters, forwardMeters]) => addMeters(center, endpoint.axis, forwardMeters, endpoint.right, rightMeters, endpoint.meters)); + return { + type: "Feature", + properties: { + type: "lane arrow", + source: "osm_turn_lanes", + osm_way_id: way.id, + direction, + lane_index: laneIndex, + maneuver, + source_asset: asset.id, + source_asset_path: asset.source, + arrow_part: partIndex, + // SVG strokes and fills are expanded separately for GeoJSON validity. + // This stable key lets the QGIS normalizer restore one rendered arrow. + custom_arrow_id: `${way.id}:${direction}:${laneIndex}:${maneuver}`, + placement_distance_meters: endpoint.placementDistance ?? PLACEMENT_DISTANCE_METERS, + placement_source: endpoint.placementSource ?? "osm_way_fallback", + }, + geometry: { type: "Polygon", coordinates: [ring] }, + }; +} + +function skip(way, direction, reason, extra = {}) { + return { source: "osm_turn_lanes", osm_way_id: way.id, direction, reason, ...extra }; +} + +function isOneway(way) { + return ["yes", "true", "1"].includes(String(way.tags.oneway || "").toLowerCase()); +} + +function metersForLat(lat) { + return { lon: 111320 * Math.cos((lat * Math.PI) / 180), lat: 110540 }; +} + +function normalizeMetersVector([dxLon, dyLat], meters) { + const x = dxLon * meters.lon; + const y = dyLat * meters.lat; + const length = Math.hypot(x, y); + return length ? [x / length, y / length] : null; +} + +function addMeters(center, axis, axisDistance, right, rightDistance, meters) { + return [ + center[0] + (axis[0] * axisDistance + right[0] * rightDistance) / meters.lon, + center[1] + (axis[1] * axisDistance + right[1] * rightDistance) / meters.lat, + ]; +} + +function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) { + const normalized = normalizeManeuver(maneuver); + if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return []; + const meters = metersForLat(center[1]); + const length = Math.hypot(axis[0], axis[1]); + if (!Number.isFinite(length) || length < 0.001) return []; + const forward = [axis[0] / length, axis[1] / length]; + const right = [forward[1], -forward[0]]; + return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) => + addMeters(center, forward, forwardMeters, right, rightMeters, meters))); +} + +function templateFor(assetId, manifest = loadManifest()) { + const asset = supportedAssets(manifest).get(assetId); + if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`); + return angularTemplate(assetId); +} + +function angularTemplate(assetId) { + const shaftWidth = 0.30; + const shaftHalf = shaftWidth / 2; + const straightBase = 1.18; + const straightTip = 1.92; + const rectangle = (minX, minY, maxX, maxY) => [ + [minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY], [minX, minY], + ]; + const throughHead = () => [[0, straightTip], [-0.42, straightBase], [-shaftHalf, straightBase], [-shaftHalf, 0], [shaftHalf, 0], [shaftHalf, straightBase], [0.42, straightBase], [0, straightTip]]; + const diagonalShaft = (side) => { + const start = [0, 0.56]; + const end = [side * 0.72, 0.96]; + const length = Math.hypot(end[0] - start[0], end[1] - start[1]); + const normal = [-(end[1] - start[1]) / length * shaftHalf, (end[0] - start[0]) / length * shaftHalf]; + return [[start[0] + normal[0], start[1] + normal[1]], [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]], [start[0] - normal[0], start[1] - normal[1]], [start[0] + normal[0], start[1] + normal[1]]]; + }; + const diagonalHead = (side) => { + const base = [side * 0.60, 0.89]; + const tip = [side * 1.22, 1.24]; + const length = Math.hypot(tip[0] - base[0], tip[1] - base[1]); + const normal = [-(tip[1] - base[1]) / length * 0.36, (tip[0] - base[0]) / length * 0.36]; + return [tip, [base[0] + normal[0], base[1] + normal[1]], [base[0] - normal[0], base[1] - normal[1]], tip]; + }; + const turnStem = (side) => { + const cutMidpoint = 0.73; + const cutRise = side * 0.084; + return [ + [-shaftHalf, 0], [shaftHalf, 0], + [shaftHalf, cutMidpoint + cutRise], [-shaftHalf, cutMidpoint - cutRise], + [-shaftHalf, 0], + ]; + }; + if (assetId === "through") return [throughHead()]; + if (assetId === "right") return [turnStem(1), diagonalShaft(1), diagonalHead(1)]; + if (assetId === "left") return [turnStem(-1), diagonalShaft(-1), diagonalHead(-1)]; + if (assetId === "through;right") return [throughHead(), diagonalShaft(1), diagonalHead(1)]; + if (assetId === "through;left") return [throughHead(), diagonalShaft(-1), diagonalHead(-1)]; + if (assetId === "through;left;right") return [throughHead(), diagonalShaft(-1), diagonalHead(-1), diagonalShaft(1), diagonalHead(1)]; + throw new Error(`No angular turn-lane template: ${assetId}`); +} + +function sourceSvgTemplateFor(asset, assetId) { + const source = fs.readFileSync(path.resolve(__dirname, "..", "..", "assets", "lane-icons", asset.source), "utf8"); + const mirrorX = asset.mirror_x === true; + const anchorX = Number(asset.anchor_x); + if (!Number.isFinite(anchorX)) throw new Error(`turn-lane asset has invalid anchor_x: ${assetId}`); + const shapes = []; + for (const match of source.matchAll(/]*)\/>|]*)\/>/g)) { + const attrs = parseSvgAttrs(match[1] || match[2]); + const strokeWidth = Number(attrs["stroke-width"] || 0); + if (match[1]) { + shapes.push(strokePolygon([[Number(attrs.x1), Number(attrs.y1)], [Number(attrs.x2), Number(attrs.y2)]], strokeWidth)); + } else { + const points = parseSvgPath(attrs.d || ""); + if (attrs.fill !== "none") shapes.push(points); + if (strokeWidth > 0) shapes.push(strokePolygon(points, strokeWidth)); + } + } + return shapes.filter((ring) => ring.length >= 4).map((ring) => ring.map(([x, y]) => [ + (mirrorX ? anchorX - x : x - anchorX) * SVG_METERS_PER_UNIT, + (23 - y) * SVG_METERS_PER_UNIT, + ])); +} + +function parseSvgAttrs(text) { + const attrs = {}; + for (const match of text.matchAll(/([\w:-]+)=(['"])(.*?)\2/g)) attrs[match[1]] = match[3]; + return attrs; +} + +function parseSvgPath(value) { + const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || []; + let index = 0; + let command = ""; + let point = [0, 0]; + let start = null; + const points = []; + const number = () => Number(tokens[index++]); + const lineTo = (x, y) => { point = [x, y]; points.push(point); }; + while (index < tokens.length) { + if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++]; + const relative = command === command.toLowerCase(); + const op = command.toUpperCase(); + if (op === "Z") { if (start) points.push(start); command = ""; continue; } + if (op === "M" || op === "L") { + const x = number(); const y = number(); + const next = relative ? [point[0] + x, point[1] + y] : [x, y]; + if (op === "M" && !start) { start = next; point = next; points.push(point); command = relative ? "l" : "L"; } else lineTo(...next); + continue; + } + if (op === "H") { lineTo(relative ? point[0] + number() : number(), point[1]); continue; } + if (op === "V") { lineTo(point[0], relative ? point[1] + number() : number()); continue; } + if (op === "C") { + const values = [number(), number(), number(), number(), number(), number()]; + const controls = relative ? values.map((n, i) => n + point[i % 2]) : values; + const origin = point; + for (let step = 1; step <= 8; step += 1) { + const t = step / 8; const u = 1 - t; + lineTo(u ** 3 * origin[0] + 3 * u ** 2 * t * controls[0] + 3 * u * t ** 2 * controls[2] + t ** 3 * controls[4], u ** 3 * origin[1] + 3 * u ** 2 * t * controls[1] + 3 * u * t ** 2 * controls[3] + t ** 3 * controls[5]); + } + continue; + } + if (op === "A") { number(); number(); number(); number(); number(); const x = number(); const y = number(); lineTo(relative ? point[0] + x : x, relative ? point[1] + y : y); continue; } + throw new Error(`Unsupported SVG path command: ${command}`); + } + return points; +} + +function strokePolygon(points, width) { + if (points.length < 2) return []; + const half = width / 2; + const left = []; const right = []; + for (let index = 0; index < points.length; index += 1) { + const prev = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + const dx = next[0] - prev[0]; const dy = next[1] - prev[1]; const length = Math.hypot(dx, dy) || 1; + const nx = -dy / length * half; const ny = dx / length * half; + left.push([points[index][0] + nx, points[index][1] + ny]); + right.unshift([points[index][0] - nx, points[index][1] - ny]); + } + return [...left, ...right, left[0]]; +} + +module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor }; diff --git a/src/geometry/lane-geometry.js b/src/geometry/lane-geometry.js new file mode 100644 index 0000000..20d36bc --- /dev/null +++ b/src/geometry/lane-geometry.js @@ -0,0 +1,161 @@ +"use strict"; + +const EARTH_RADIUS_METERS = 6371008.8; + +function laneCenterline(lane) { + const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null; + if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null; + const vertices = ring.slice(0, -1); + if (!vertices.every(validCoordinate)) return null; + const half = vertices.length / 2; + if (!Number.isInteger(half) || half < 2) return null; + const centerline = vertices.slice(0, half).map((point, index) => [ + (point[0] + vertices[vertices.length - 1 - index][0]) / 2, + (point[1] + vertices[vertices.length - 1 - index][1]) / 2, + ]); + return polylineLength(centerline) > 0.01 ? centerline : null; +} + +function orientPolyline(polyline, reference) { + if (!polyline?.length || !reference?.length) return null; + const forward = projectedDistanceAlong(reference, polyline.at(-1)) - projectedDistanceAlong(reference, polyline[0]); + if (Math.abs(forward) < 0.01) return null; + return forward > 0 ? polyline.map(copyCoordinate) : [...polyline].reverse().map(copyCoordinate); +} + +function stitchPolylines(polylines, maxGapMeters) { + if (!polylines.length) return null; + const result = []; + for (const polyline of polylines) { + if (!polyline?.length) return null; + if (result.length && haversineMeters(result.at(-1), polyline[0]) > maxGapMeters) return null; + appendCoordinates(result, polyline); + } + return result; +} + +function projectedDistanceAlong(polyline, point) { + let traversed = 0; + let best = { distance: Infinity, along: 0, lateral: 0 }; + for (let index = 1; index < polyline.length; index += 1) { + const start = polyline[index - 1]; + const end = polyline[index]; + const meters = metersAt((start[1] + end[1]) / 2); + const dx = (end[0] - start[0]) * meters.lon; + const dy = (end[1] - start[1]) * meters.lat; + const px = (point[0] - start[0]) * meters.lon; + const py = (point[1] - start[1]) * meters.lat; + const length = Math.hypot(dx, dy); + if (length < 0.001) continue; + const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length))); + const offsetX = px - dx * ratio; + const offsetY = py - dy * ratio; + const distance = Math.hypot(offsetX, offsetY); + if (distance < best.distance) { + const rightX = dy / length; + const rightY = -dx / length; + best = { + distance, + along: traversed + length * ratio, + lateral: offsetX * rightX + offsetY * rightY, + }; + } + traversed += length; + } + return best.along; +} + +function lateralOffsetFrom(polyline, point) { + let best = null; + for (let index = 1; index < polyline.length; index += 1) { + const start = polyline[index - 1]; + const end = polyline[index]; + const meters = metersAt((start[1] + end[1]) / 2); + const dx = (end[0] - start[0]) * meters.lon; + const dy = (end[1] - start[1]) * meters.lat; + const px = (point[0] - start[0]) * meters.lon; + const py = (point[1] - start[1]) * meters.lat; + const length = Math.hypot(dx, dy); + if (length < 0.001) continue; + const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length))); + const offsetX = px - dx * ratio; + const offsetY = py - dy * ratio; + const distance = Math.hypot(offsetX, offsetY); + if (!best || distance < best.distance) { + best = { distance, lateral: offsetX * dy / length - offsetY * dx / length }; + } + } + return best; +} + +function polylineMidpoint(polyline) { + const target = polylineLength(polyline) / 2; + let traversed = 0; + for (let index = 1; index < polyline.length; index += 1) { + const length = haversineMeters(polyline[index - 1], polyline[index]); + if (traversed + length >= target) { + const ratio = length ? (target - traversed) / length : 0; + return [ + polyline[index - 1][0] + (polyline[index][0] - polyline[index - 1][0]) * ratio, + polyline[index - 1][1] + (polyline[index][1] - polyline[index - 1][1]) * ratio, + ]; + } + traversed += length; + } + return polyline.length ? copyCoordinate(polyline.at(-1)) : null; +} + +function polylineLength(polyline) { + let total = 0; + for (let index = 1; index < (polyline?.length || 0); index += 1) { + total += haversineMeters(polyline[index - 1], polyline[index]); + } + return total; +} + +function haversineMeters(a, b) { + const lat1 = degreesToRadians(a[1]); + const lat2 = degreesToRadians(b[1]); + const dLat = degreesToRadians(b[1] - a[1]); + const dLon = degreesToRadians(b[0] - a[0]); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h))); +} + +function appendCoordinates(target, coordinates) { + for (const coordinate of coordinates) { + if (!sameCoordinate(target.at(-1), coordinate)) target.push(copyCoordinate(coordinate)); + } +} + +function validCoordinate(value) { + return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]); +} + +function sameCoordinate(a, b) { + return Boolean(a && b && a[0] === b[0] && a[1] === b[1]); +} + +function copyCoordinate(coordinate) { + return [coordinate[0], coordinate[1]]; +} + +function metersAt(latitude) { + return { lon: 111320 * Math.cos(degreesToRadians(latitude)), lat: 111320 }; +} + +function degreesToRadians(value) { + return value * Math.PI / 180; +} + +module.exports = { + appendCoordinates, + haversineMeters, + laneCenterline, + lateralOffsetFrom, + orientPolyline, + polylineLength, + polylineMidpoint, + projectedDistanceAlong, + stitchPolylines, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..a435240 --- /dev/null +++ b/src/index.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = { + laneGeometry: require("./geometry/lane-geometry"), + gaodeReference: require("./reference/gaode"), + turnLaneArrows: require("./compile/turn-lane-arrows"), + complexJunction: require("./compile/complex-junction"), + osm: require("./osm"), + trafficSignals: require("./traffic-signals"), + nativeTrafficSignals: require("./native-traffic-signals"), + nativeRoad: require("./compile/native-road"), + compiler: require("./compile/compiler"), +}; diff --git a/src/native-traffic-signals.js b/src/native-traffic-signals.js new file mode 100644 index 0000000..58069f5 --- /dev/null +++ b/src/native-traffic-signals.js @@ -0,0 +1,49 @@ +"use strict"; + +const fs = require("fs"); +const { parseOsm } = require("./osm"); +const { + buildTrafficSignalFeatures, + buildTrafficSignalsFromFeatures, + validateTrafficSignalFeatures, + validateTrafficSignalSourceReferences, +} = require("./traffic-signals"); + +const SCHEMA = "native-traffic-signals/v1"; + +function loadOrGenerate(file, osmText, stopLines, intersections) { + if (fs.existsSync(file)) { + const document = JSON.parse(fs.readFileSync(file, "utf8")); + try { + return validateDocument(document, osmText); + } catch (error) { + // OSM edits can invalidate the stable identities in a document that was + // itself generated from OSM. User-authored documents must remain strict. + if (document?.provenance === "generated:osm-controls" && isStaleSourceReferenceError(error)) { + return generate(osmText, stopLines, intersections); + } + throw error; + } + } + return generate(osmText, stopLines, intersections); +} + +function isStaleSourceReferenceError(error) { + return error instanceof Error && /^traffic signal feature \d+: (approach_id .* is not present on OSM control|control_id .* is not present in the current OSM)/.test(error.message); +} + +function generate(osmText, stopLines, intersections) { + const controls = parseOsm(osmText).trafficSignalControls; + return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) }; +} + +function validateDocument(value, osmText) { + if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`); + const assemblies = validateTrafficSignalFeatures(value.assemblies); + if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls); + return { schema: SCHEMA, provenance: value.provenance || "native", assemblies }; +} + +function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); } + +module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime }; diff --git a/src/osm.js b/src/osm.js new file mode 100644 index 0000000..03e8f75 --- /dev/null +++ b/src/osm.js @@ -0,0 +1,102 @@ +"use strict"; + +function parseOsm(xml) { + const boundsMatch = xml.match(/]*)\/?\s*>/); + const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {}; + const candidateBounds = { + minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat), + maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat), + }; + const bounds = Object.values(candidateBounds).every(Number.isFinite) ? candidateBounds : null; + const nodes = new Map(); + const trafficSignalControls = []; + const nodePattern = /]*?)(?:\/>|>([\s\S]*?)<\/node>)/g; + for (const match of xml.matchAll(nodePattern)) { + 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; + nodes.set(attrs.id, coordinate); + const tags = parseTags(match[2] || ""); + if (tags.highway === "traffic_signals") { + trafficSignalControls.push({ id: attrs.id, longitude: coordinate[0], latitude: coordinate[1], tags }); + } + } + const ways = []; + for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { + const attrs = xmlAttrs(match[1]); + if (attrs.action === "delete") continue; + const body = match[2]; + const refs = []; + for (const ndMatch of body.matchAll(/]*)\/?\s*>/g)) { + const ref = xmlAttrs(ndMatch[1]).ref; + if (ref && nodes.has(ref)) refs.push(ref); + } + if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags: parseTags(body) }); + } + for (const control of trafficSignalControls) { + const arms = []; + for (const way of ways) { + if (!isMotorRoad(way.tags)) continue; + for (let index = 0; index < way.refs.length; index += 1) { + if (way.refs[index] !== control.id) continue; + for (const neighborIndex of [index - 1, index + 1]) { + const neighbor = way.refs[neighborIndex]; + if (!neighbor || !nodes.has(neighbor)) continue; + const neighborPoint = nodes.get(neighbor); + arms.push({ + headingDegrees: headingBetween(control, neighborPoint), + wayId: String(way.id), + neighborNodeId: String(neighbor), + }); + } + } + } + control.arms = dedupeHeadings(arms); + control.junctionType = control.arms.length === 3 ? "T" : control.arms.length === 4 ? "cross" : "other"; + } + return { bounds, nodes, ways, trafficSignalControls }; +} + +function isMotorRoad(tags) { + const highway = tags.highway || ""; + return highway && tags.area !== "yes" && !new Set([ + "footway", "path", "pedestrian", "steps", "cycleway", "service", "track", + "bridleway", "corridor", "elevator", "platform", "construction", + ]).has(highway); +} + +function headingBetween(from, to) { + const latitude = (from.latitude + to[1]) / 2 * Math.PI / 180; + return Math.atan2((to[0] - from.longitude) * Math.cos(latitude), to[1] - from.latitude) * 180 / Math.PI; +} + +function dedupeHeadings(arms) { + const normalized = (value) => ((value % 360) + 360) % 360; + const distance = (a, b) => Math.abs(((a - b + 540) % 360) - 180); + const result = []; + for (const arm of arms) { + arm.headingDegrees = normalized(arm.headingDegrees); + if (!result.some((other) => distance(other.headingDegrees, arm.headingDegrees) <= 25)) result.push(arm); + } + return result.sort((a, b) => a.headingDegrees - b.headingDegrees); +} + +function xmlAttrs(text) { + const attrs = {}; + for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) { + attrs[match[1]] = match[2] !== undefined ? match[2] : match[3]; + } + return attrs; +} + +function parseTags(body) { + const tags = {}; + for (const match of body.matchAll(/]*)\/?\s*>/g)) { + const tag = xmlAttrs(match[1]); + if (tag.k) tags[tag.k] = tag.v || ""; + } + return tags; +} + +module.exports = { parseOsm }; diff --git a/src/reference/gaode.js b/src/reference/gaode.js new file mode 100644 index 0000000..7d8eac1 --- /dev/null +++ b/src/reference/gaode.js @@ -0,0 +1,231 @@ +"use strict"; + +const fs = require("fs"); + +const PI = Math.PI; +const EARTH_A = 6378245.0; +const EARTH_EE = 0.00669342162296594323; + +function transformLat(x, y) { + let value = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); + value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; + value += (20 * Math.sin(y * PI) + 40 * Math.sin(y / 3 * PI)) * 2 / 3; + value += (160 * Math.sin(y / 12 * PI) + 320 * Math.sin(y * PI / 30)) * 2 / 3; + return value; +} + +function transformLon(x, y) { + let value = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + value += (20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2 / 3; + value += (20 * Math.sin(x * PI) + 40 * Math.sin(x / 3 * PI)) * 2 / 3; + value += (150 * Math.sin(x / 12 * PI) + 300 * Math.sin(x / 30 * PI)) * 2 / 3; + return value; +} + +// This is the standard local inverse approximation used for GCJ-02 reference +// data. It is intentionally kept separate from native road geometry, whose +// source coordinates remain WGS84. +function gcj02ToWgs84(coordinate) { + const [longitude, latitude] = coordinate; + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) throw new Error("Reference coordinate must be finite"); + const dLat = transformLat(longitude - 105, latitude - 35); + const dLon = transformLon(longitude - 105, latitude - 35); + const radLat = latitude / 180 * PI; + const magic = 1 - EARTH_EE * Math.sin(radLat) ** 2; + const sqrtMagic = Math.sqrt(magic); + return [ + longitude - dLon * 180 / (EARTH_A / sqrtMagic * Math.cos(radLat) * PI), + latitude - dLat * 180 / (EARTH_A * (1 - EARTH_EE) / (magic * sqrtMagic) * PI), + ]; +} + +function mapCoordinates(coordinates, mapper) { + if (typeof coordinates[0] === "number") return mapper(coordinates); + return coordinates.map((value) => mapCoordinates(value, mapper)); +} + +function convertGeoJson(document) { + if (!document || document.type !== "FeatureCollection" || !Array.isArray(document.features)) { + throw new Error("Reference must be a GeoJSON FeatureCollection"); + } + return { + ...document, + crs: undefined, + features: document.features.map((feature) => { + if (!feature || !feature.geometry || !feature.geometry.coordinates) throw new Error("Reference feature is missing geometry"); + return { ...feature, geometry: { ...feature.geometry, coordinates: mapCoordinates(feature.geometry.coordinates, gcj02ToWgs84) } }; + }), + }; +} + +function coordinatesOf(document) { + const points = []; + for (const feature of document.features || []) walkCoordinates(feature.geometry?.coordinates, points); + return points; +} + +function walkCoordinates(value, points) { + if (!Array.isArray(value) || !value.length) return; + if (typeof value[0] === "number") { + points.push(value); + return; + } + for (const child of value) walkCoordinates(child, points); +} + +function boundsOf(document) { + const points = coordinatesOf(document); + if (!points.length) throw new Error("Reference contains no coordinates"); + return { + minLon: Math.min(...points.map((point) => point[0])), + minLat: Math.min(...points.map((point) => point[1])), + maxLon: Math.max(...points.map((point) => point[0])), + maxLat: Math.max(...points.map((point) => point[1])), + }; +} + +function centerOf(bounds) { + return [(bounds.minLon + bounds.maxLon) / 2, (bounds.minLat + bounds.maxLat) / 2]; +} + +function distanceMeters(first, second) { + const lonScale = 111320 * Math.cos(first[1] * PI / 180); + return Math.hypot((second[0] - first[0]) * lonScale, (second[1] - first[1]) * 111320); +} + +function parseOsmNodes(xml) { + const nodes = []; + for (const match of xml.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { + const attrs = {}; + for (const item of match[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[item[1]] = item[2] ?? item[3]; + if (!attrs.id || !Number.isFinite(Number(attrs.lon)) || !Number.isFinite(Number(attrs.lat))) continue; + const tags = {}; + for (const item of (match[2] || "").matchAll(/]*)\/?\s*>/g)) { + const tag = {}; + for (const attr of item[1].matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) tag[attr[1]] = attr[2] ?? attr[3]; + if (tag.k) tags[tag.k] = tag.v || ""; + } + nodes.push({ id: String(attrs.id), coordinate: [Number(attrs.lon), Number(attrs.lat)], tags }); + } + return nodes; +} + +function nearestNode(nodes, coordinate, nodeId) { + if (nodeId) { + const exact = nodes.find((node) => node.id === String(nodeId)); + if (!exact) throw new Error(`OSM node not found: ${nodeId}`); + return { ...exact, distanceMeters: distanceMeters(exact.coordinate, coordinate), match: "node-id" }; + } + const candidates = nodes.map((node) => ({ ...node, distanceMeters: distanceMeters(node.coordinate, coordinate) })); + candidates.sort((first, second) => first.distanceMeters - second.distanceMeters); + if (!candidates[0]) throw new Error("OSM contains no usable nodes"); + return { ...candidates[0], match: "nearest-node" }; +} + +function bboxIntersectionRatio(first, second) { + const width = Math.max(0, Math.min(first.maxLon, second.maxLon) - Math.max(first.minLon, second.minLon)); + const height = Math.max(0, Math.min(first.maxLat, second.maxLat) - Math.max(first.minLat, second.minLat)); + const intersection = width * height; + const firstArea = Math.max(0, first.maxLon - first.minLon) * Math.max(0, first.maxLat - first.minLat); + const secondArea = Math.max(0, second.maxLon - second.minLon) * Math.max(0, second.maxLat - second.minLat); + return intersection / Math.max(firstArea + secondArea - intersection, Number.EPSILON); +} + +// A complex junction is compiled as one cluster of `complex_part` polygons in +// road_surface.geojson, not as a per-node feature in intersection_surface. +// Match it by cluster id, or by whichever cluster core sits nearest the node. +function complexClusterSurface(nativeRoadSurfaceFile, node, clusterId) { + if (!nativeRoadSurfaceFile || !fs.existsSync(nativeRoadSurfaceFile)) return null; + const surface = JSON.parse(fs.readFileSync(nativeRoadSurfaceFile, "utf8")); + const parts = (surface.features || []).filter((item) => item.properties?.cluster_id && item.properties?.complex_part); + const cores = parts.filter((item) => item.properties.complex_part === "core" && Array.isArray(item.properties.center)); + if (!cores.length) return null; + const core = clusterId + ? cores.find((item) => String(item.properties.cluster_id) === String(clusterId)) + : [...cores].sort((first, second) => distanceMeters(first.properties.center, node.coordinate) - distanceMeters(second.properties.center, node.coordinate))[0]; + if (!core) return null; + const features = parts.filter((item) => item.properties.cluster_id === core.properties.cluster_id); + return { clusterId: core.properties.cluster_id, core, features }; +} + +function inspectReference({ referenceFile, osmFile, nativeIntersectionFile, nativeRoadSurfaceFile, nodeId, clusterId }) { + const source = JSON.parse(fs.readFileSync(referenceFile, "utf8")); + const converted = convertGeoJson(source); + const referenceBounds = boundsOf(converted); + const referenceCenter = centerOf(referenceBounds); + const nodes = parseOsmNodes(fs.readFileSync(osmFile, "utf8")); + const matchedNode = nearestNode(nodes, referenceCenter, nodeId); + const native = JSON.parse(fs.readFileSync(nativeIntersectionFile, "utf8")); + const feature = (native.features || []).find((item) => item.properties?.osm_node_id === matchedNode.id); + const cluster = feature ? null : complexClusterSurface(nativeRoadSurfaceFile, matchedNode, clusterId); + const matchedFeatures = feature ? [feature] : cluster?.features || null; + const nativeBounds = matchedFeatures ? boundsOf({ features: matchedFeatures }) : null; + const diagnostics = []; + if (!matchedFeatures) diagnostics.push(nativeRoadSurfaceFile ? "No native intersection surface or complex cluster matched the OSM node" : "No native intersection surface matched the OSM node; pass --native-road-surface to also search complex junction clusters"); + return { + schema: "gaode-junction-reference-comparison/v2", + source: { file: referenceFile, coordinateSystem: "GCJ-02", featureCount: converted.features.length }, + conversion: { target: "WGS84", method: "gcj02-inverse-approximation" }, + reference: { bounds: referenceBounds, center: referenceCenter }, + matchedOsmNode: { id: matchedNode.id, coordinate: matchedNode.coordinate, tags: matchedNode.tags, match: matchedNode.match, centerDistanceMeters: matchedNode.distanceMeters }, + nativeIntersection: nativeBounds ? { + kind: feature ? "junction-node" : "complex-cluster", + clusterId: cluster?.clusterId || null, + featureCount: matchedFeatures.length, + bounds: nativeBounds, + bboxIoU: bboxIntersectionRatio(referenceBounds, nativeBounds), + centerOffsetMeters: distanceMeters(referenceCenter, centerOf(nativeBounds)), + featureProperties: feature ? feature.properties : cluster.core.properties, + } : null, + diagnostics, + converted, + matchedFeatures, + }; +} + +function localReferenceSvg({ converted, nativeIntersection, center, radiusMeters = 180 }) { + const width = 1000; + const height = 1000; + const lonScale = 111320 * Math.cos(center[1] * PI / 180); + const project = (point) => [ + width / 2 + (point[0] - center[0]) * lonScale * width / (radiusMeters * 2), + height / 2 - (point[1] - center[1]) * 111320 * height / (radiusMeters * 2), + ]; + const pathFor = (coordinates) => { + const parts = []; + const appendLine = (line, close) => { + if (!line?.length) return; + const [firstX, firstY] = project(line[0]); + parts.push(`M ${firstX.toFixed(1)} ${firstY.toFixed(1)}`); + for (const point of line.slice(1)) { + const [x, y] = project(point); + parts.push(`L ${x.toFixed(1)} ${y.toFixed(1)}`); + } + if (close) parts.push("Z"); + }; + const visit = (value) => { + if (!Array.isArray(value) || !value.length) return; + if (typeof value[0] === "number") return; + if (typeof value[0][0] === "number") appendLine(value, value.length > 2); + else value.forEach(visit); + }; + visit(coordinates); + return parts.join(" "); + }; + const color = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" }; + const references = converted.features.map((feature) => { + const type = feature.properties?.type || "unknown"; + return ``; + }).join("\n"); + const nativePaths = (nativeIntersection?.features || []).map((feature) => ``).join("\n"); + return ` + + + ${references} + ${nativePaths} + + Gaode reference (type colors) / native intersection (red) +`; +} + +module.exports = { gcj02ToWgs84, convertGeoJson, boundsOf, parseOsmNodes, nearestNode, inspectReference, localReferenceSvg }; diff --git a/src/traffic-signals.js b/src/traffic-signals.js new file mode 100644 index 0000000..e3f8d11 --- /dev/null +++ b/src/traffic-signals.js @@ -0,0 +1,330 @@ +"use strict"; + +const fs = require("fs"); +const crypto = require("crypto"); +const { parseOsm } = require("./osm"); + +const EARTH_RADIUS = 6371008.8; +const CURB_OFFSET_METERS = 5.2; +const MAST_REACH_METERS = 4.5; +const SIGNAL_LAYOUT = Object.freeze({ + poleHeightMeters: 6.7, poleRadiusMeters: 0.13, armWidthMeters: 0.21, + mastHeightMeters: 6.25, headCenterHeightMeters: 6.25, + headWidthMeters: 0.68, headDepthMeters: 0.30, headBodyHeightMeters: 1.62, + lensRadiusMeters: 0.22, lensDepthMeters: 0.07, lensFaceOffsetMeters: 0.18, + lensVerticalOffsetsMeters: [0.49, -0.01, -0.51], + countdownLateralMeters: 1.15, countdownFaceOffsetMeters: 0.05, + countdownWidthMeters: 0.82, countdownDepthMeters: 0.14, + countdownHeightMeters: 0.56, countdownVerticalOffsetMeters: 0.0, +}); + +function buildTrafficSignalFeatures(stopLines, intersections, controls = []) { + const centers = (intersections.features || []).map((feature, index) => { + const point = polygonCenter(feature.geometry); + return { id: `intersection-${index + 1}`, point, radius: polygonRadius(feature.geometry, point) }; + }).filter((entry) => entry.point); + const clusteredStops = new Map(); + for (const feature of stopLines.features || []) { + const clusterId = feature.properties?.cluster_id; + const point = polygonCenter(feature.geometry); + if (!clusterId || !point) continue; + if (!clusteredStops.has(clusterId)) clusteredStops.set(clusterId, []); + clusteredStops.get(clusterId).push(point); + } + for (const [clusterId, points] of clusteredStops) { + if (points.length < 3) continue; + const point = points.reduce((sum, item) => [sum[0] + item[0] / points.length, sum[1] + item[1] / points.length], [0, 0]); + centers.push({ id: `cluster-${clusterId}`, clusterId, point, radius: Math.max(...points.map((item) => metersBetween(point, item))) }); + } + const candidates = []; + for (const feature of stopLines.features || []) { + const center = polygonCenter(feature.geometry); + if (!center) continue; + const clusterId = feature.properties?.cluster_id; + const intersection = clusterId ? centers.find((entry) => entry.clusterId === clusterId) : nearestCenter(center, centers); + if (!intersection || metersBetween(center, intersection.point) > 32) continue; + const axis = roadAxis(feature.geometry, center, intersection.point); + if (!axis) continue; + const right = [axis[1], -axis[0]]; + candidates.push({ + intersectionId: intersection.id, center, axis, + point: intersection.clusterId + ? moveMeters(center, right, CURB_OFFSET_METERS) + : moveMeters(moveMeters(intersection.point, axis, intersection.radius + 3.2), right, CURB_OFFSET_METERS), + headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), + matchHeadingDegrees: intersection.clusterId + ? normalizeDegrees(Math.atan2(-axis[0], -axis[1]) * 180 / Math.PI) + : null, + }); + } + const features = []; + for (const control of controls) { + const controlPoint = [Number(control.longitude), Number(control.latitude)]; + if (!controlPoint.every(Number.isFinite) || !Array.isArray(control.arms) || control.arms.length < 3) continue; + const intersection = nearestCenter(controlPoint, centers); + if (!intersection || metersBetween(controlPoint, intersection.point) > 32) continue; + const arms = matchOsmArms(candidates.filter((item) => item.intersectionId === intersection.id), controlPoint, control.arms); + const groups = phaseGroups(arms); + arms.forEach((candidate, index) => { + const fallbackArmId = `heading-${Math.round(normalizeDegrees(candidate.osmArm?.headingDegrees || 0) * 1000)}`; + const sourceWayId = String(candidate.osmArm?.wayId || "legacy"); + const neighborNodeId = String(candidate.osmArm?.neighborNodeId || fallbackArmId); + const approachId = `${sourceWayId}:${neighborNodeId}`; + const signalUid = `osm-${String(control.id)}-${sourceWayId}-${neighborNodeId}`; + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: candidate.point.slice() }, + properties: { + signal_uid: signalUid, display_id: signalUid, control_id: String(control.id), + approach_id: approachId, source_way_id: sourceWayId, + // These are independent assembly controls. heading_deg remains a + // migration hint for older native documents only. + mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90), + face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180), + phase_group: groups[index], + mast_reach_m: MAST_REACH_METERS, + stop_lon: candidate.center[0], stop_lat: candidate.center[1], + enabled: true, z_offset_m: 0, + }, + }); + }); + } + return validateTrafficSignalFeatures({ type: "FeatureCollection", features }); +} + +function validateTrafficSignalFeatures(collection) { + if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) { + throw new Error("Traffic signal assemblies must be a FeatureCollection"); + } + const uids = new Set(); + const displayIds = new Set(); + const features = collection.features.map((feature, index) => { + const label = `traffic signal feature ${index + 1}`; + if (feature?.geometry?.type !== "Point" || !Array.isArray(feature.geometry.coordinates) || + feature.geometry.coordinates.length < 2 || !feature.geometry.coordinates.slice(0, 2).every(Number.isFinite)) { + throw new Error(`${label}: geometry must be a finite Point`); + } + const input = feature.properties || {}; + const text = (key, required = true) => { + const value = input[key] == null ? "" : String(input[key]).trim(); + if (required && !value) throw new Error(`${label}: missing ${key}`); + return value; + }; + const number = (key, options = {}) => { + if (input[key] === null || input[key] === undefined || input[key] === "") { + throw new Error(`${label}: missing ${key}`); + } + const value = Number(input[key]); + if (!Number.isFinite(value) || (options.min != null && value < options.min) || (options.max != null && value > options.max)) { + throw new Error(`${label}: invalid ${key} '${input[key]}'`); + } + return value; + }; + const signalUid = text("signal_uid"); + if (!/^osm-[A-Za-z0-9_.:-]+$/.test(signalUid)) throw new Error(`${label}: invalid signal_uid '${signalUid}'`); + if (uids.has(signalUid)) throw new Error(`Duplicate signal_uid '${signalUid}'`); + uids.add(signalUid); + const displayId = text("display_id", false); + if (displayId && displayIds.has(displayId)) throw new Error(`Duplicate display_id '${displayId}'`); + if (displayId) displayIds.add(displayId); + const phaseGroup = number("phase_group", { min: 0, max: 1 }); + if (!Number.isInteger(phaseGroup)) throw new Error(`${label}: phase_group must be 0 or 1`); + const enabled = normalizeBoolean(input.enabled, label); + const controlId = text("control_id"); + const approachId = text("approach_id"); + const sourceWayId = text("source_way_id"); + if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`); + const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`; + if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`); + const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg")); + if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) { + throw new Error(`${label}: missing mast_heading_deg`); + } + if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) { + throw new Error(`${label}: missing face_heading_deg`); + } + const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === "" + ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90) + : normalizeDegrees(number("mast_heading_deg")); + const faceHeading = input.face_heading_deg == null || input.face_heading_deg === "" + ? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180) + : normalizeDegrees(number("face_heading_deg")); + return { + type: "Feature", + geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) }, + properties: { + ...input, signal_uid: signalUid, display_id: displayId, + control_id: controlId, approach_id: approachId, + source_way_id: sourceWayId, + // Retain the legacy value only for migration compatibility. Runtime + // geometry is entirely defined by mast_heading_deg and face_heading_deg. + heading_deg: legacyHeading, + mast_heading_deg: mastHeading, face_heading_deg: faceHeading, + phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }), + stop_lon: number("stop_lon", { min: -180, max: 180 }), + stop_lat: number("stop_lat", { min: -90, max: 90 }), + enabled, z_offset_m: number("z_offset_m", { min: -20, max: 100 }), + }, + }; + }); + return { type: "FeatureCollection", features }; +} + +function buildTrafficSignalsFromFeatures(collection) { + const normalized = validateTrafficSignalFeatures(collection); + const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => { + const p = feature.properties; + const point = feature.geometry.coordinates; + const mastAxis = headingVector(p.mast_heading_deg); + return { + id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id, + nodeKey: signalNodeKey(p.signal_uid), + controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id, + phaseGroup: p.phase_group, longitude: point[0], latitude: point[1], + stopLongitude: p.stop_lon, stopLatitude: p.stop_lat, + // Existing Blender readers require headingDegrees. It is a compatibility + // alias only; the independent mast/face fields below define all geometry. + headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg, + mastHeadingDegrees: p.mast_heading_deg, + faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m, + zOffsetMeters: p.z_offset_m, + pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m), + }; + }); + return { version: 3, layout: SIGNAL_LAYOUT, signals }; +} + +function signalNodeKey(signalUid) { + return `ts_${crypto.createHash("sha256").update(signalUid).digest("hex").slice(0, 16)}`; +} + +function reconcileTrafficSignalSourceReferences(collection, controls) { + const normalized = validateTrafficSignalFeatures(collection); + const approachesByControl = new Map((controls || []).map((control) => [ + String(control.id), + new Set((control.arms || []).map((arm) => `${String(arm.wayId)}:${String(arm.neighborNodeId)}`)), + ])); + const kept = []; + const dropped = []; + for (const [index, feature] of normalized.features.entries()) { + const { control_id: controlId, approach_id: approachId, signal_uid: signalUid } = feature.properties; + const approaches = approachesByControl.get(controlId); + if (!approaches) { + dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-control", message: `control_id '${controlId}' is not present in the current OSM` }); + continue; + } + if (!approaches.has(approachId)) { + dropped.push({ index: index + 1, signalUid, controlId, approachId, reason: "missing-approach", message: `approach_id '${approachId}' is not present on OSM control '${controlId}'` }); + continue; + } + kept.push(feature); + } + return { collection: { ...normalized, features: kept }, dropped }; +} + +function validateTrafficSignalSourceReferences(collection, controls) { + const { collection: reconciled, dropped } = reconcileTrafficSignalSourceReferences(collection, controls); + if (dropped.length) throw new Error(`traffic signal feature ${dropped[0].index}: ${dropped[0].message}`); + return reconciled; +} + +function buildTrafficSignals(stopLines, intersections, controls = []) { + return buildTrafficSignalsFromFeatures(buildTrafficSignalFeatures(stopLines, intersections, controls)); +} + +function readTrafficSignalFeatures(stopLinePath, intersectionPath, osmPath) { + const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls; + return buildTrafficSignalFeatures( + JSON.parse(fs.readFileSync(stopLinePath, "utf8")), + JSON.parse(fs.readFileSync(intersectionPath, "utf8")), controls, + ); +} + +function readTrafficSignals(editablePath, osmPath = null) { + const collection = JSON.parse(fs.readFileSync(editablePath, "utf8")); + if (osmPath) { + const controls = parseOsm(fs.readFileSync(osmPath, "utf8")).trafficSignalControls; + validateTrafficSignalSourceReferences(collection, controls); + } + return buildTrafficSignalsFromFeatures(collection); +} + +function normalizeBoolean(value, label) { + if (value === true || value === 1 || value === "1" || String(value).toLowerCase() === "true" || String(value).toLowerCase() === "yes") return true; + if (value === false || value === 0 || value === "0" || String(value).toLowerCase() === "false" || String(value).toLowerCase() === "no") return false; + throw new Error(`${label}: invalid enabled '${value}'`); +} + +function uniqueApproachArms(candidates, controlPoint) { + const sorted = candidates.map((candidate) => ({ ...candidate, armHeading: normalizeDegrees(headingBetween(controlPoint, candidate.center)), controlDistance: metersBetween(controlPoint, candidate.center) })) + .sort((a, b) => a.armHeading - b.armHeading || a.controlDistance - b.controlDistance); + const arms = []; + for (const candidate of sorted) if (!arms.some((arm) => angularDistance(arm.armHeading, candidate.armHeading) <= 25)) arms.push(candidate); + return arms; +} + +function matchOsmArms(candidates, controlPoint, osmArms) { + const remaining = candidates.map((candidate) => ({ ...candidate, armHeading: candidate.matchHeadingDegrees ?? normalizeDegrees(headingBetween(controlPoint, candidate.center)) })); + if (!osmArms.length) return uniqueApproachArms(remaining, controlPoint); + return osmArms.map((osmArm) => { + let bestIndex = -1; let bestDistance = Infinity; + remaining.forEach((item, index) => { + const directedDistance = angularDistance(item.armHeading, osmArm.headingDegrees); + const distance = item.matchHeadingDegrees == null + ? directedDistance + : Math.min( + angularDistance(item.matchHeadingDegrees, osmArm.headingDegrees), + angularDistance(item.matchHeadingDegrees + 180, osmArm.headingDegrees), + ); + if (distance < bestDistance) { bestDistance = distance; bestIndex = index; } + }); + const candidate = bestIndex >= 0 && bestDistance <= 45 ? remaining.splice(bestIndex, 1)[0] : fallbackCandidate(controlPoint, osmArm); + return { ...candidate, osmArm }; + }); +} + +function fallbackCandidate(controlPoint, osmArm) { + const outward = headingVector(osmArm.headingDegrees); const axis = [-outward[0], -outward[1]]; + const center = moveMeters(controlPoint, outward, 8); const farSide = moveMeters(controlPoint, axis, 3.2); + return { center, axis, point: moveMeters(farSide, [axis[1], -axis[0]], CURB_OFFSET_METERS), armHeading: normalizeDegrees(osmArm.headingDegrees), headingDegrees: normalizeDegrees(Math.atan2(axis[0], axis[1]) * 180 / Math.PI), fallback: true }; +} + +function phaseGroups(arms) { + const groups = Array(arms.length).fill(1); if (arms.length < 2) return groups; + let main = [0, 1]; let best = -1; + for (let a = 0; a < arms.length; a += 1) for (let b = a + 1; b < arms.length; b += 1) { const opposition = angularDistance(arms[a].armHeading, arms[b].armHeading); if (opposition > best) { best = opposition; main = [a, b]; } } + groups[main[0]] = 0; groups[main[1]] = 0; return groups; +} + +function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) { + const face = headingVector(faceHeadingDegrees); + const head = moveMeters(pole, mastAxis, mastReach); + const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset }); + const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters); + const faceRight = [-face[1], face[0]]; + const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters); + return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } }; +} + +function polygonCenter(geometry) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; if (!ring || ring.length < 4) return null; const points = ring.slice(0, -1); return [points.reduce((s, p) => s + p[0], 0) / points.length, points.reduce((s, p) => s + p[1], 0) / points.length]; } +function polygonRadius(geometry, center) { const ring = geometry?.type === "Polygon" ? geometry.coordinates?.[0] : null; return ring && center ? Math.max(...ring.slice(0, -1).map((point) => metersBetween(center, point)), 0) : 0; } +function roadAxis(geometry, center, target) { const ring = geometry?.coordinates?.[0]; if (!ring || ring.length < 3) return null; let longest; for (let i = 0; i < ring.length - 1; i += 1) { const dx = (ring[i + 1][0] - ring[i][0]) * Math.cos(center[1] * Math.PI / 180); const dy = ring[i + 1][1] - ring[i][1]; const length = Math.hypot(dx, dy); if (!longest || length > longest.length) longest = { dx, dy, length }; } if (!longest?.length) return null; let axis = [-longest.dy / longest.length, longest.dx / longest.length]; const toward = [(target[0] - center[0]) * Math.cos(center[1] * Math.PI / 180), target[1] - center[1]]; if (axis[0] * toward[0] + axis[1] * toward[1] < 0) axis = [-axis[0], -axis[1]]; return axis; } +function nearestCenter(point, centers) { return centers.map((entry) => ({ ...entry, distance: metersBetween(point, entry.point) })).sort((a, b) => a.distance - b.distance)[0] || null; } +function metersBetween(a, b) { const lat = (a[1] + b[1]) / 2 * Math.PI / 180; return Math.hypot((a[0] - b[0]) * Math.cos(lat), a[1] - b[1]) * Math.PI / 180 * EARTH_RADIUS; } +function moveMeters(point, vector, meters) { const scale = 180 / Math.PI / EARTH_RADIUS; return [point[0] + vector[0] * meters * scale / Math.cos(point[1] * Math.PI / 180), point[1] + vector[1] * meters * scale]; } +function headingBetween(from, to) { const latitude = (from[1] + to[1]) / 2 * Math.PI / 180; return Math.atan2((to[0] - from[0]) * Math.cos(latitude), to[1] - from[1]) * 180 / Math.PI; } +function headingVector(degrees) { const radians = degrees * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; } +function normalizeDegrees(value) { return ((value % 360) + 360) % 360; } +function angularDistance(a, b) { return Math.abs(((a - b + 540) % 360) - 180); } + +module.exports = { + SIGNAL_LAYOUT, + signalNodeKey, + buildTrafficSignalFeatures, + validateTrafficSignalFeatures, + validateTrafficSignalSourceReferences, + buildTrafficSignalsFromFeatures, + buildTrafficSignals, + readTrafficSignalFeatures, + readTrafficSignals, +}; diff --git a/test/fixtures/fengshu-er-road.osm b/test/fixtures/fengshu-er-road.osm new file mode 100644 index 0000000..300a4af --- /dev/null +++ b/test/fixtures/fengshu-er-road.osm @@ -0,0 +1,11091 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..23fb241 --- /dev/null +++ b/test/index.js @@ -0,0 +1,18 @@ +"use strict"; + +const assert = require("assert/strict"); +const fs = require("fs"); +const path = require("path"); +const compiler = require("../src"); + +assert.equal(typeof compiler.laneGeometry.laneCenterline, "function"); +assert.equal(typeof compiler.gaodeReference.convertGeoJson, "function"); +assert.equal(typeof compiler.turnLaneArrows.buildCustomTurnLaneArrows, "function"); +assert.equal(typeof compiler.complexJunction.buildComplexJunctionGeometry, "function"); +assert.equal(typeof compiler.nativeRoad.compileRoadModel, "function"); +const fixture = path.join(__dirname, "fixtures", "fengshu-er-road.osm"); +assert.ok(fs.existsSync(fixture)); +assert.throws(() => compiler.compiler.validateInput({ id: "area" }), /RoadCompilerInput/); +const model = compiler.nativeRoad.compileRoadModel(fs.readFileSync(fixture, "utf8"), { schema: "native-road-overrides/v1", overrides: [] }); +assert.equal(model.roads.length, 24); +console.log("road compiler package tests passed");