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:
@@ -73,7 +73,7 @@ assert.ok(geometry.connectors.features.every((feature) => feature.properties.nod
|
||||
assert.ok(geometry.movements.length >= geometry.connectors.features.length);
|
||||
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
|
||||
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v3"));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-shared-cutback/v4-shared-node-split"));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
|
||||
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
|
||||
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
|
||||
@@ -117,6 +117,180 @@ assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.prop
|
||||
assert.ok(crossGeometry.roadSurface.features.every((feature) => Math.min(...feature.geometry.coordinates[0].map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 4));
|
||||
const exteriorRings = (geometry) => geometry.type === "Polygon" ? [geometry.coordinates[0]] : geometry.coordinates.map((polygon) => polygon[0]);
|
||||
assert.ok(crossGeometry.sidewalkSurface.features.every((feature) => Math.min(...exteriorRings(feature.geometry).flat().map((point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320))) > 5));
|
||||
const crossTemplateGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
|
||||
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.1 }] },
|
||||
});
|
||||
assert.equal(crossTemplateGeometry.intersectionSurface.features[0].properties.template, "cross-v1");
|
||||
assert.equal(crossTemplateGeometry.intersectionSurface.features[0].properties.rule, "junction-cross-template/v1");
|
||||
assert.ok(crossTemplateGeometry.diagnostics.some((item) => item.rule === "junction-template-applied"));
|
||||
assert.notDeepEqual(crossTemplateGeometry.intersectionSurface.features[0].geometry.coordinates, crossGeometry.intersectionSurface.features[0].geometry.coordinates);
|
||||
assert.deepEqual(crossTemplateGeometry.connectors, crossGeometry.connectors);
|
||||
assert.deepEqual(crossTemplateGeometry.vehicleStopLines, crossGeometry.vehicleStopLines);
|
||||
const crossCutbackTemplateGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
|
||||
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.25, cutbackMultiplier: 1.25 }] },
|
||||
});
|
||||
const crossCutbackSurface = crossCutbackTemplateGeometry.intersectionSurface.features[0];
|
||||
assert.ok(crossCutbackSurface.properties.cutback_m > crossGeometry.intersectionSurface.features[0].properties.cutback_m);
|
||||
assert.ok(crossCutbackSurface.properties.surface_area_m2 > crossGeometry.intersectionSurface.features[0].properties.surface_area_m2);
|
||||
const crossApproachTemplateGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
|
||||
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.25, cutbackMultiplier: 1.25, approachWidthMultiplier: 1.45, approachLengthMeters: 24 }] },
|
||||
});
|
||||
const templateApproaches = crossApproachTemplateGeometry.roadSurface.features.filter((feature) => feature.properties.template === "cross-v1");
|
||||
assert.equal(templateApproaches.length, 4);
|
||||
assert.ok(templateApproaches.every((feature) => feature.properties.approach_width_m > feature.properties.width_m && feature.properties.approach_length_m === 24));
|
||||
const clusterApproachGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
|
||||
junctionTemplates: { enabled: true, references: [], clusters: [{ id: "cross-cluster", nodeIds: ["2", "missing"], template: "complex-junction-v1", approachWidthMultiplier: 1.45, approachLengthMeters: 24, coreRadiusMeters: 18 }] },
|
||||
});
|
||||
const clusterApproaches = clusterApproachGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === "cross-cluster");
|
||||
assert.equal(clusterApproaches.filter((feature) => feature.properties.kind === "complex-reference-surface" || feature.properties.kind === "complex-composite").length, 0);
|
||||
assert.ok(clusterApproachGeometry.diagnostics.some((item) => item.rule === "complex-junction-insufficient-nodes"));
|
||||
assert.equal(clusterApproachGeometry.intersectionSurface.features.length, 0);
|
||||
|
||||
const fengshuOsm = fs.readFileSync(path.join(__dirname, "..", "inputs", "osm", "枫树二路.osm"), "utf8");
|
||||
const fengshuModel = compileRoadModel(fengshuOsm, empty);
|
||||
const fengshuCluster = {
|
||||
id: "zhushanhu-fengshu-complex",
|
||||
template: "complex-junction-v1",
|
||||
referenceFile: path.join(__dirname, "..", "inputs", "osm", "珠山湖大道(枫树二路)口.geojson"),
|
||||
approachWidthMultiplier: 1.45,
|
||||
approachLengthMeters: 32,
|
||||
coreRadiusMeters: 28,
|
||||
outerRadiusExtraMeters: 18,
|
||||
nodeIds: ["8005332807", "8024512135", "8024512145", "8024512147"],
|
||||
};
|
||||
const fengshuGeometry = compileGeometry(fengshuModel, empty, { edgeLines: false, junctionTemplates: { enabled: true, references: [], clusters: [fengshuCluster] } });
|
||||
const fengshuSidewalkOverrides = { schema: "native-road-overrides/v1", overrides: [
|
||||
{ id: "generic-complex-sidewalk-a", kind: "road", roadId: "road:way/99505317:forward", sidewalkRight: true },
|
||||
{ id: "generic-complex-sidewalk-b", kind: "road", roadId: "road:way/858770821:segment/2:forward", sidewalkLeft: true },
|
||||
] };
|
||||
const fengshuSidewalkGeometry = compileGeometry(compileRoadModel(fengshuOsm, fengshuSidewalkOverrides), fengshuSidewalkOverrides, { edgeLines: false, junctionTemplates: { enabled: true, references: [], clusters: [fengshuCluster] } });
|
||||
const fengshuConfiguredSidewalks = fengshuSidewalkGeometry.sidewalkSurface.features.filter((feature) => feature.properties.provenance === "native-road-sidewalk/v1" && feature.properties.cluster_id === fengshuCluster.id);
|
||||
assert.equal(fengshuConfiguredSidewalks.length, 2, "sidewalk overrides on complex approaches remain road-side features");
|
||||
const fengshuConfiguredCore = fengshuSidewalkGeometry.roadSurface.features.find((feature) => feature.properties.kind === "complex-core");
|
||||
assert.ok(fengshuConfiguredSidewalks.every((feature) => !ringsOverlap(feature.geometry.coordinates[0], fengshuConfiguredCore.geometry.coordinates[0])), "configured sidewalks stop at the complex-junction handoff instead of entering its core");
|
||||
assert.equal(fengshuSidewalkGeometry.sidewalkSurface.features.filter((feature) => feature.properties.provenance === "native-road-sidewalk-corner/v1" && fengshuCluster.nodeIds.includes(String(feature.properties.osm_node_id))).length, 0, "ordinary sidewalk corners are not generated at member nodes of a complex junction");
|
||||
const genericComplexSource = fs.readFileSync(path.join(__dirname, "lib", "complex-junction.js"), "utf8");
|
||||
assert.doesNotMatch(genericComplexSource, /8005332807|8024512135|8024512145|8024512147|858770823|珠山湖|枫树二路/, "complex junction generator must not contain sample-specific identifiers");
|
||||
const renamedCluster = { ...fengshuCluster, id: "generic-complex-validation-cluster" };
|
||||
const renamedGeometry = compileGeometry(fengshuModel, empty, { edgeLines: false, junctionTemplates: { enabled: true, references: [], clusters: [renamedCluster] } });
|
||||
assert.equal(renamedGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === renamedCluster.id && feature.properties.kind === "complex-core").length, 1, "complex geometry is selected by template and topology, not cluster name");
|
||||
assert.equal(renamedGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === renamedCluster.id && feature.properties.kind === "complex-approach").length, 8);
|
||||
assert.equal(renamedGeometry.laneCenterlines.features.filter((feature) => feature.properties.cluster_id === renamedCluster.id && feature.properties.cluster_preview).length, 24);
|
||||
assert.equal(renamedGeometry.connectors.features.length, fengshuGeometry.connectors.features.length, "renaming a complex cluster does not change native connector topology");
|
||||
const fengshuRoadParts = fengshuGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind);
|
||||
assert.equal(fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-core").length, 1);
|
||||
assert.equal(fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-approach").length, 8);
|
||||
const fengshuCore = fengshuRoadParts.find((feature) => feature.properties.kind === "complex-core");
|
||||
assert.equal(fengshuCore.properties.corner_rounding_ratio, .16, "complex core rounds its four road-corner transitions without adding exterior sidewalk platforms");
|
||||
assert.equal(fengshuCore.geometry.coordinates[0].length, 33, "complex core samples a rounded boundary at each of its approach-edge corners");
|
||||
const fengshuApproachOuterExtents = fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-approach").map((feature) => {
|
||||
const extent = radialExtent(feature, fengshuCore.properties.center, feature.properties.heading_deg);
|
||||
return extent[1];
|
||||
});
|
||||
assert.equal(fengshuApproachOuterExtents.filter((extent) => Math.abs(extent - (fengshuCore.properties.radius_m + 18)) < .15).length, 7, "all full-length complex approaches reach the same outer handoff radius");
|
||||
assert.ok(fengshuApproachOuterExtents.every((extent) => extent <= fengshuCore.properties.radius_m + 18.15), "short OSM approaches stop at their continuation node instead of overshooting it");
|
||||
const fengshuCornerFillets = fengshuRoadParts.filter((feature) => feature.properties.kind === "complex-corner-fillet");
|
||||
assert.equal(fengshuCornerFillets.length, 4, "every adjacent-arm corner of a complex junction gets a curb fillet");
|
||||
assert.deepEqual(fengshuCornerFillets.map((feature) => feature.properties.corner_index).sort(), [1, 2, 3, 4]);
|
||||
assert.ok(fengshuCornerFillets.every((feature) => feature.properties.corner_radius_m === 12), "corner fillets use the configured curb radius");
|
||||
// The fillet has to stay between the core it smooths and the handoff radius
|
||||
// where the arms become ordinary road surface; a fillet reaching past either
|
||||
// bound would cut the junction open or bridge across the carriageways.
|
||||
assert.ok(fengshuCornerFillets.every((feature) => {
|
||||
const extent = radialExtent(feature, fengshuCore.properties.center, feature.properties.bisector_heading);
|
||||
return extent[1] > fengshuCore.properties.radius_m * .5 && extent[1] < fengshuCore.properties.radius_m + 18;
|
||||
}), "corner fillets fill the wedge between the complex core and the approach handoff radius");
|
||||
assert.ok(fengshuCornerFillets.every((feature) => !ringSelfIntersects(feature.geometry.coordinates[0])), "corner fillet rings are simple polygons");
|
||||
const fengshuBoundaryRoadSurfaces = fengshuGeometry.roadSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && !feature.properties.kind);
|
||||
assert.equal(fengshuBoundaryRoadSurfaces.length, 7, "complex junction keeps its OSM-derived exterior road surfaces");
|
||||
const fengshuPreviewLanes = fengshuGeometry.laneCenterlines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.cluster_preview);
|
||||
assert.equal(fengshuPreviewLanes.length, 24);
|
||||
assert.equal(fengshuPreviewLanes.filter((feature) => feature.properties.incoming).length, 12);
|
||||
assert.equal(fengshuPreviewLanes.filter((feature) => feature.properties.maneuver === "outbound").length, 12);
|
||||
const fengshuBoundaryLanes = fengshuGeometry.laneCenterlines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.cluster_boundary_clipped);
|
||||
assert.equal(fengshuBoundaryLanes.length, 24, "eight exterior three-lane carriageways remain visible outside the complex junction boundary");
|
||||
assert.ok(fengshuBoundaryLanes.every((feature) => !feature.properties.cluster_preview_hidden && !feature.properties.cluster_internal));
|
||||
assert.ok(fengshuPreviewLanes.every((feature) => {
|
||||
const road = fengshuModel.roads.find((candidate) => candidate.id === feature.properties.road_id);
|
||||
const laneHeading = bearingDegrees(feature.geometry.coordinates[0], feature.geometry.coordinates.at(-1));
|
||||
const roadHeading = bearingDegrees(road.centerline[0], road.centerline.at(-1));
|
||||
return axialHeadingDifference(laneHeading, roadHeading) < 3;
|
||||
}), "complex-junction lane centerlines remain parallel to their source OSM carriageways");
|
||||
assert.equal(fengshuGeometry.laneSeparators.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.provenance === "native-road-complex-lane-separator/v1").length, 16);
|
||||
const fengshuComplexControls = [
|
||||
...fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id),
|
||||
...fengshuGeometry.vehicleStopLines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id),
|
||||
];
|
||||
assert.equal(fengshuGeometry.laneSeparators.features
|
||||
.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.provenance === "native-road-complex-lane-separator/v1").length, 16, "complex lane separators remain present after control-line clipping");
|
||||
const fengshuArmCrosswalks = fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-crosswalk");
|
||||
assert.ok(fengshuArmCrosswalks.length > 24);
|
||||
assert.equal(new Set(fengshuArmCrosswalks.map((feature) => feature.properties.direction.toFixed(3))).size, 4, "four arm crosswalk groups define the central frame sides");
|
||||
assert.ok(fengshuArmCrosswalks.every((feature) => {
|
||||
const road = fengshuModel.roads.find((candidate) => candidate.id === feature.properties.road_id);
|
||||
const stripe = feature.geometry.coordinates[0];
|
||||
const stripeHeading = bearingDegrees(stripe[0], stripe[1]);
|
||||
const roadHeading = bearingDegrees(road.centerline[0], road.centerline.at(-1));
|
||||
return axialHeadingDifference(stripeHeading, roadHeading) < 3
|
||||
&& distance(stripe[0], stripe[1]) > distance(stripe[1], stripe[2]) * 5;
|
||||
}), "each arm crosswalk stripe is long and axially parallel to its source OSM carriageway");
|
||||
assert.equal(fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-corner-crosswalk").length, 24);
|
||||
assert.equal(fengshuGeometry.vehicleStopLines.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id).length, 4);
|
||||
const fengshuStopApproachArrows = fengshuGeometry.directionArrows.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.provenance === "native-road-complex-preview-arrow/v2-stop-anchored");
|
||||
assert.equal(new Set(fengshuStopApproachArrows.map((feature) => feature.properties.lane_id)).size, 12, "each of the four complex approaches gets one maneuver arrow per generated lane");
|
||||
assert.deepEqual([...new Set(fengshuStopApproachArrows.map((feature) => feature.properties.maneuver))].sort(), ["left", "right", "through"], "complex approach arrows preserve lane maneuver assignments");
|
||||
assert.ok(fengshuStopApproachArrows.every((feature) => feature.properties.placement_distance_from_stop_meters === 8), "complex approach arrows are anchored upstream of their stop lines");
|
||||
assert.ok(fengshuStopApproachArrows.every((feature) => {
|
||||
const lane = fengshuGeometry.laneCenterlines.features.find((candidate) => candidate.properties.native_id === feature.properties.lane_id);
|
||||
return lane && Math.abs(feature.properties.travel_heading_deg - bearingDegrees(lane.geometry.coordinates[0], lane.geometry.coordinates.at(-1))) < .01;
|
||||
}), "complex stop-approach arrows retain the lane travel heading instead of the reversed placement axis");
|
||||
assert.equal(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-median").length, 0, "complex junctions do not add center green belts");
|
||||
assert.equal(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-corner-island").length, 4);
|
||||
assert.equal(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id && feature.properties.kind === "complex-sidewalk-corner").length, 0, "complex junctions do not add center sidewalk/green-belt links");
|
||||
assert.ok(fengshuGeometry.sidewalkSurface.features.filter((feature) => feature.properties.cluster_id === fengshuCluster.id).every((feature) => !ringSelfIntersects(feature.geometry.coordinates[0])));
|
||||
const fengshuCenter = fengshuRoadParts.find((feature) => feature.properties.kind === "complex-core").properties.center;
|
||||
for (let cornerIndex = 1; cornerIndex <= 4; cornerIndex += 1) {
|
||||
const island = fengshuGeometry.sidewalkSurface.features.find((feature) => feature.properties.kind === "complex-corner-island" && feature.properties.corner_index === cornerIndex);
|
||||
const crossing = fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.kind === "complex-corner-crosswalk" && feature.properties.corner_index === cornerIndex);
|
||||
const heading = crossing[0].properties.direction;
|
||||
const firstArm = groupedArmCrosswalk(fengshuArmCrosswalks, crossing[0].properties.from_heading);
|
||||
const secondArm = groupedArmCrosswalk(fengshuArmCrosswalks, crossing[0].properties.to_heading);
|
||||
const expectedFrameCorner = supportLineIntersection(featureGroupCenter(firstArm), headingForArmGroup(firstArm) + 90, featureGroupCenter(secondArm), headingForArmGroup(secondArm) + 90, fengshuCenter);
|
||||
assert.ok(expectedFrameCorner, `corner ${cornerIndex} adjacent arm frame supports intersect`);
|
||||
assert.ok(distance(featureGroupCenter(crossing), expectedFrameCorner) > 3.1 && distance(featureGroupCenter(crossing), expectedFrameCorner) < 3.7, `corner ${cornerIndex} diagonal crossing leaves compact room for a safety island beyond the frame corner`);
|
||||
assert.ok(distance(crossing[0].properties.frame_corner, expectedFrameCorner) < .15, `corner ${cornerIndex} records its derived frame corner`);
|
||||
assert.ok(distance(island.properties.frame_corner, expectedFrameCorner) < .15, `corner ${cornerIndex} safety island occupies its derived frame corner`);
|
||||
const islandExtent = radialExtent(island, fengshuCenter, heading);
|
||||
const crossingExtent = radialExtent(crossing, fengshuCenter, heading);
|
||||
assert.ok(crossing.every((feature) => axialHeadingDifference(bearingDegrees(feature.geometry.coordinates[0][0], feature.geometry.coordinates[0][1]), heading) > 75), `corner ${cornerIndex} stripes remain transverse to the pedestrian path`);
|
||||
const armVertices = [...firstArm, ...secondArm].flatMap((feature) => feature.geometry.coordinates[0]);
|
||||
assert.ok(island.properties.base_points.every((point) => Math.min(...armVertices.map((vertex) => distance(point, vertex))) < .4), `corner ${cornerIndex} safety island base follows both long-crosswalk endpoints`);
|
||||
assert.ok(island.properties.base_width_m > 6 && island.properties.base_width_m < 12, `corner ${cornerIndex} safety island fills the space left between two road-clipped crosswalks`);
|
||||
assert.ok(island.properties.crossing_clearance_m >= .15 && island.properties.crossing_clearance_m <= .25, `corner ${cornerIndex} safety island fills the gap without extending through the diagonal crossing`);
|
||||
assert.equal(island.geometry.coordinates[0].length, 7, `corner ${cornerIndex} safety island rounds the existing gap boundary without adding an outer platform`);
|
||||
assert.ok(islandExtent[1] + .25 < crossingExtent[0], `corner ${cornerIndex} crossing starts beyond its safety island`);
|
||||
}
|
||||
for (const heading of [...new Set(fengshuArmCrosswalks.map((feature) => feature.properties.direction))]) {
|
||||
const crossing = fengshuGeometry.crosswalks.features.filter((feature) => feature.properties.kind === "complex-crosswalk" && Math.abs(feature.properties.direction - heading) < 1);
|
||||
const stopLine = fengshuGeometry.vehicleStopLines.features.filter((feature) => Math.abs(feature.properties.direction - heading) < 2);
|
||||
assert.ok(crossing.every((feature) => feature.properties.span_m > 20 && feature.properties.span_m <= feature.properties.road_envelope_span_m), "arm crosswalk spans the two three-lane carriageways without exceeding the OSM road envelope");
|
||||
assert.ok(crossing.every((feature) => Math.abs(feature.properties.road_envelope_span_m - feature.properties.span_m - .7) < .05), "arm crosswalk keeps a curb inset on both road edges");
|
||||
const lateralSpan = lateralExtent(crossing, fengshuCenter, heading);
|
||||
assert.ok(lateralSpan[1] - lateralSpan[0] > crossing[0].properties.span_m - 1, "arm crosswalk geometry spans both three-lane carriageways and their median gap");
|
||||
assert.ok(radialExtent(crossing, fengshuCenter, heading)[1] + .5 < radialExtent(stopLine, fengshuCenter, heading)[0], "incoming stop line is beyond the arm crosswalk");
|
||||
assert.ok(radialExtent(stopLine, fengshuCenter, heading)[0] - radialExtent(crossing, fengshuCenter, heading)[1] < 1.2, "incoming stop line stays close to the arm crosswalk");
|
||||
}
|
||||
assert.deepEqual(crossCutbackTemplateGeometry.connectors.features.map((feature) => feature.properties.native_id).sort(), crossGeometry.connectors.features.map((feature) => feature.properties.native_id).sort());
|
||||
assert.deepEqual(crossCutbackTemplateGeometry.movements.map((movement) => movement.id).sort(), crossGeometry.movements.map((movement) => movement.id).sort());
|
||||
const missingReferenceGeometry = compileGeometry(compileRoadModel(crossOsm, empty), empty, {
|
||||
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", referenceFile: "/tmp/native-road-missing-junction-reference.geojson", cornerRadiusMultiplier: 1.1 }] },
|
||||
});
|
||||
assert.equal(missingReferenceGeometry.intersectionSurface.features[0].properties.template, null);
|
||||
assert.ok(missingReferenceGeometry.diagnostics.some((item) => item.rule === "junction-template-reference-missing"));
|
||||
const tTemplateGeometry = compileGeometry(compileRoadModel(osm, empty), empty, {
|
||||
junctionTemplates: { enabled: true, references: [{ nodeId: "2", template: "cross-v1", cornerRadiusMultiplier: 1.1 }] },
|
||||
});
|
||||
assert.ok(tTemplateGeometry.intersectionSurface.features.every((feature) => feature.properties.template === null));
|
||||
const crossSidewalkCorners = crossGeometry.sidewalkSurface.features.filter((feature) => feature.properties.kind === "corner");
|
||||
assert.equal(crossSidewalkCorners.length, 4);
|
||||
// A rounded sidewalk corner must sample both the curb and outer boundaries.
|
||||
@@ -217,6 +391,67 @@ try {
|
||||
function distance(a, b) {
|
||||
return Math.hypot((b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180), (b[1] - a[1]) * 111320);
|
||||
}
|
||||
function bearingDegrees(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 axialHeadingDifference(first, second) {
|
||||
const difference = Math.abs(((first - second + 180) % 360 + 360) % 360 - 180);
|
||||
return Math.min(difference, 180 - difference);
|
||||
}
|
||||
function ringSelfIntersects(ring) {
|
||||
const orientation = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[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 (orientation(ring[first], ring[first + 1], ring[second]) * orientation(ring[first], ring[first + 1], ring[second + 1]) < 0
|
||||
&& orientation(ring[second], ring[second + 1], ring[first]) * orientation(ring[second], ring[second + 1], ring[first + 1]) < 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function radialExtent(features, center, heading) {
|
||||
const list = Array.isArray(features) ? features : [features];
|
||||
const radians = heading * Math.PI / 180;
|
||||
const values = list.flatMap((feature) => feature.geometry.coordinates[0].map((point) => {
|
||||
const east = (point[0] - center[0]) * 111320 * Math.cos(center[1] * Math.PI / 180);
|
||||
const north = (point[1] - center[1]) * 111320;
|
||||
return east * Math.sin(radians) + north * Math.cos(radians);
|
||||
}));
|
||||
return [Math.min(...values), Math.max(...values)];
|
||||
}
|
||||
function lateralExtent(features, center, heading) {
|
||||
return radialExtent(features, center, heading + 90);
|
||||
}
|
||||
function groupedArmCrosswalk(features, heading) {
|
||||
return features.filter((feature) => directedHeadingDifference(feature.properties.direction, heading) < 1);
|
||||
}
|
||||
function directedHeadingDifference(first, second) {
|
||||
return Math.abs(((first - second + 180) % 360 + 360) % 360 - 180);
|
||||
}
|
||||
function headingForArmGroup(features) {
|
||||
return features[0].properties.direction;
|
||||
}
|
||||
function featureGroupCenter(features) {
|
||||
const centers = features.map((feature) => {
|
||||
const ring = feature.geometry.coordinates[0].slice(0, -1);
|
||||
return ring.reduce((sum, point) => [sum[0] + point[0] / ring.length, sum[1] + point[1] / ring.length], [0, 0]);
|
||||
});
|
||||
return centers.reduce((sum, point) => [sum[0] + point[0] / centers.length, sum[1] + point[1] / centers.length], [0, 0]);
|
||||
}
|
||||
function supportLineIntersection(firstPoint, firstHeading, secondPoint, secondHeading, origin) {
|
||||
const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180);
|
||||
const local = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320];
|
||||
const direction = (heading) => [Math.sin(heading * Math.PI / 180), Math.cos(heading * Math.PI / 180)];
|
||||
const first = local(firstPoint); const second = local(secondPoint);
|
||||
const a = direction(firstHeading); const b = direction(secondHeading);
|
||||
const denominator = a[0] * b[1] - a[1] * b[0];
|
||||
if (Math.abs(denominator) < 1e-6) return null;
|
||||
const delta = [second[0] - first[0], second[1] - first[1]];
|
||||
const along = (delta[0] * b[1] - delta[1] * b[0]) / denominator;
|
||||
return [origin[0] + (first[0] + a[0] * along) / lonScale, origin[1] + (first[1] + a[1] * along) / 111320];
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user