feat: parameterize complex junction geometry with Gaode reference
- 高德 GeoJSON 参考流程: `scripts/lib/gaode-junction-reference.js` 与 `scripts/inspect-junction-reference.js` 将 GCJ-02 参考转换为 WGS84, 按 node id/最近距离关联 OSM, 支持普通路口面和 `complex-cluster` 两种匹配。 - 复合路口模板 `complex-junction-v1`: `scripts/lib/complex-junction.js` 用参考 几何校准 core 半径, 生成路口面、进口路面、斑马线、停止线、角部圆角与安全岛; 拓扑/信号/连接全部沿用 OSM/native。 - 车道中心线控制要素避让: `compileLaneCenterlines` 现接收模板已产出的斑马线/停止线, 新增 `trimLaneOutsideControls` 按到路口中心的半径定向裁剪; 标线源几何同步裁剪, 不再 越过斑马线继续画到核心区。拓扑几何不变, connector 集合前后一致。 - 复合路口人行道转角: `buildComplexJunctionGeometry` 沿已定义的路缘生成 2m 宽转角带, 复用圆角曲线, 通过 `islands` 通道并入 `sidewalk_surface`; 自交或坐标非有限时报 `complex-junction-sidewalk-corner-fallback` 并跳过。 - 新增诊断: `complex-junction-configured-radius-ignored`、 `lane-centerline-fully-inside-control`、`complex-junction-sidewalk-corner-fallback`。 - 死码清理: 移除未被调用的 `clusterApproachRing`。 - spec 更新: `.trellis/spec/pipeline/cli-and-stages.md` 复合路口小节补充控制要素 避让顺序、人行道转角契约、Validation 矩阵三行; 索引新增导航。 - 任务产物 `08-19-gaode-junction-reference`: 8 条验收标准全部实测记录, Scope Drift / Verification Log / Known Gaps 三节沉淀本次工作。 Regression: test:native-road / test:road-workbench / test:preflight / test:native-preview-traffic / test:package-contract / test:traffic-signals / test:gaode-junction-reference 全绿; road:check ok=true, errors=[]。
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
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"]);
|
||||
@@ -22,6 +23,9 @@ 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) {
|
||||
@@ -244,33 +248,171 @@ function nearbyManualCandidates(endpoints, from) {
|
||||
|
||||
function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
|
||||
const diagnostics = [...model.diagnostics];
|
||||
const junctionPlans = compileJunctionPlans(model);
|
||||
const junctionPlans = compileJunctionPlans(model, 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 ring = roadRing(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), totalWidth);
|
||||
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, 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] } });
|
||||
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] } });
|
||||
}
|
||||
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
|
||||
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);
|
||||
const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
|
||||
const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics);
|
||||
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls);
|
||||
const sidewalks = compileSidewalkSurfaces(model, 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);
|
||||
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 }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, 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) {
|
||||
@@ -305,7 +447,7 @@ 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) {
|
||||
function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics, options = {}) {
|
||||
const features = [];
|
||||
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
|
||||
const segments = new Map();
|
||||
@@ -317,18 +459,24 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti
|
||||
const forward = roads.find((road) => road.direction === "forward");
|
||||
const backward = roads.find((road) => road.direction === "backward");
|
||||
if (roads.length !== 2 || !forward || !backward || forward.highway === "service" || backward.highway === "service") continue;
|
||||
const line = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
|
||||
const 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 <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) {
|
||||
const placement = pointAndAxisAlongLine(line, start + markLength / 2);
|
||||
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);
|
||||
if (ringsOverlapControl([clearanceRing], controlFeatures)) continue;
|
||||
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, 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] } }); }
|
||||
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;
|
||||
@@ -381,21 +529,35 @@ function offsetByMeters(point, axis, meters) { return unproject([axis[0] * meter
|
||||
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) {
|
||||
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 roadLanes = lanes.byRoadId.get(road.id) || [];
|
||||
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, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-lane-separator/v1" };
|
||||
if (style.pattern === "solid") { const ring = roadRing(centerline, 0.12); if (ring) separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); }
|
||||
else for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) { const placement = pointAndAxisAlongLine(centerline, distance); if (!placement) continue; const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0); separators.push({ type: "Feature", properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties }, geometry: { type: "Polygon", coordinates: [ring] } }); }
|
||||
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));
|
||||
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) {
|
||||
@@ -413,12 +575,31 @@ function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans
|
||||
const center = pointAlongLine([...lane.coordinates].reverse(), placement);
|
||||
const rings = arrowRingsAt(maneuver, center, axis);
|
||||
if (!rings.length) continue;
|
||||
for (let part = 0; part < rings.length; part += 1) turnArrows.push({ type: "Feature", properties: { native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`, road_id: road.id, lane_id: lane.id, osm_way_ids: road.osmWayIds.join(","), direction: road.direction, lane_index: lane.index, maneuver, arrow_part: part, placement_distance_meters: placement, provenance: "native-road-turn-arrow/v1" }, geometry: { type: "Polygon", coordinates: [rings[part]] } });
|
||||
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) {
|
||||
@@ -445,7 +626,7 @@ function ringsOverlap(first, second) {
|
||||
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) {
|
||||
function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}) {
|
||||
const features = [];
|
||||
const byWay = new Map();
|
||||
for (const road of model.roads) {
|
||||
@@ -461,22 +642,27 @@ function compileSidewalkSurfaces(model, diagnostics, junctionPlans) {
|
||||
["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 = trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
|
||||
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, 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({ 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));
|
||||
features.push(...compileSidewalkCorners(model, junctionPlans, options));
|
||||
return features;
|
||||
}
|
||||
|
||||
function compileSidewalkCorners(model, junctionPlans) {
|
||||
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);
|
||||
@@ -589,10 +775,15 @@ function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
|
||||
}
|
||||
|
||||
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
|
||||
const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature]));
|
||||
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]));
|
||||
@@ -627,14 +818,29 @@ function pointOnSegment(point, a, b) {
|
||||
return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
|
||||
}
|
||||
|
||||
function compileLaneCenterlines(model, diagnostics, junctionPlans) {
|
||||
// `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;
|
||||
@@ -643,14 +849,84 @@ function compileLaneCenterlines(model, diagnostics, junctionPlans) {
|
||||
// driver's left so tag positions and generated lane IDs have one meaning.
|
||||
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
|
||||
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
|
||||
if (!coordinates) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "invalid-lane-centerline", "无法为该道路生成车道中心线。", road.centerline[0])); continue; }
|
||||
const 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);
|
||||
features.push({ type: "Feature", properties: { native_id: lane.id, road_id: road.id, lane_index: lane.index, source: "native-road-lane-centerline/v1" }, geometry: { type: "LineString", coordinates } });
|
||||
// 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);
|
||||
}
|
||||
return { features, byRoadId };
|
||||
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) {
|
||||
@@ -683,7 +959,8 @@ function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans)
|
||||
const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus };
|
||||
if (length < .4) { movements.push(movement); continue; }
|
||||
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; }
|
||||
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } });
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -793,9 +1070,11 @@ function polygonAreaMeters(ring) {
|
||||
return Math.abs(twiceArea) / 2;
|
||||
}
|
||||
|
||||
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics) {
|
||||
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);
|
||||
@@ -817,21 +1096,56 @@ function compileJunctionSurfaces(model, junctionPlans, connectors, movements, di
|
||||
}
|
||||
const surfaceAreaMeters = polygonAreaMeters(ring);
|
||||
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
|
||||
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: segmentIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, approach_area_m2: Math.round(approachAreaMeters * 10) / 10, surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10, expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100, rule: "junction-shared-cutback/v4-shared-node-split" }, geometry: { type: "Polygon", coordinates: [ring] } });
|
||||
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));
|
||||
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", 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 compileJunctionPlans(model) {
|
||||
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;
|
||||
@@ -839,11 +1153,14 @@ function compileJunctionPlans(model) {
|
||||
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 cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
||||
const baseCutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
||||
const node = endpoints[0].coordinate;
|
||||
const boundary = junctionBoundary(approaches, node, cutbackMeters);
|
||||
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;
|
||||
plans.set(nodeId, { node, segmentIds, approaches, cutbackMeters, boundary: boundary.points, boundaryMode: boundary.mode, boundaryFallbacks: boundary.fallbacks });
|
||||
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;
|
||||
}
|
||||
@@ -863,13 +1180,13 @@ function junctionApproaches(model, endpoints) {
|
||||
});
|
||||
}
|
||||
|
||||
function junctionBoundary(approaches, node, cutbackMeters) {
|
||||
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 = headingAtEndpoint(approach.line);
|
||||
const half = approach.widthMeters / 2;
|
||||
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 });
|
||||
}
|
||||
@@ -886,7 +1203,7 @@ function junctionBoundary(approaches, node, cutbackMeters) {
|
||||
// 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);
|
||||
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;
|
||||
@@ -894,13 +1211,50 @@ function junctionBoundary(approaches, node, cutbackMeters) {
|
||||
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) {
|
||||
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]);
|
||||
@@ -915,10 +1269,51 @@ function roundedCorner(node, first, second, firstHeading, secondHeading) {
|
||||
// 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 control = unproject(intersection, origin);
|
||||
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)];
|
||||
@@ -978,6 +1373,42 @@ function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
|
||||
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]; }
|
||||
|
||||
Reference in New Issue
Block a user