Files
osmWorkflow/scripts/test-native-road.js

493 lines
52 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road");
const { compileArea } = require("./compile-native-roads");
const { checkArea } = require("./check-native-roads");
const osm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="lanes" v="2"/><tag k="sidewalk" v="both"/></way><way id="11"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
const empty = { schema: "native-road-overrides/v1", overrides: [] };
const initial = compileRoadModel(osm, empty);
assert.equal(initial.roads.length, 3);
const target = initial.roads.find((road) => road.id === "road:way/10:forward");
const overrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "road-width", kind: "road", roadId: target.id, widthMeters: 9, laneCount: 2, sidewalkLeft: false }] }, initial);
const model = compileRoadModel(osm, overrides);
const edited = model.roads.find((road) => road.id === target.id);
assert.equal(edited.widthMeters, 9);
assert.equal(edited.provenance.widthMeters, "override:road-width");
assert.equal(edited.sidewalkLeft, false);
const geometry = compileGeometry(model);
assert.equal(geometry.roadSurface.features.length, 2);
assert.equal(compileGeometry(model, empty, { edgeLines: false }).edgeLines.features.length, 0);
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.sidewalkSurface.features.length, 2);
assert.ok(geometry.sidewalkSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum, road) => sum + road.laneCount, 0));
assert.ok(geometry.laneSeparators.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-lane-separator/v1"));
const separator = geometry.laneSeparators.features[0];
const separatorOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "separator-style", kind: "lane-separator-style", roadId: separator.properties.road_id, leftLaneIndex: separator.properties.left_lane_index, rightLaneIndex: separator.properties.right_lane_index, color: "yellow", pattern: "solid" }] }, model);
const styledSeparators = compileGeometry(model, separatorOverride).laneSeparators.features.filter((feature) => feature.properties.road_id === separator.properties.road_id && feature.properties.left_lane_index === separator.properties.left_lane_index);
assert.ok(styledSeparators.length > 0 && styledSeparators.every((feature) => feature.properties.effective_style === "yellow-solid"));
assert.ok(geometry.centerLines.features.length > 0);
assert.ok(geometry.centerLines.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-center-line/v1" && feature.properties.dash_length_m === 2 && feature.properties.dash_gap_m === 2));
for (const feature of geometry.centerLines.features) {
const ring = feature.geometry.coordinates[0];
const lengths = [distance(ring[0], ring[1]), distance(ring[1], ring[2])].sort((a, b) => a - b);
assert.ok(Math.abs(lengths[0] - .25) < .01 && Math.abs(lengths[1] - 2) < .01);
assert.ok(feature.properties.segment_id && feature.properties.directional_road_ids && feature.properties.osm_way_ids && feature.properties.placement_rule);
}
const centerLineOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "center-white-solid", kind: "center-line-style", segmentId: target.segmentId, color: "white", pattern: "solid" }] }, model);
const styledCenterLines = compileGeometry(model, centerLineOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId);
assert.ok(styledCenterLines.length > 0);
assert.ok(styledCenterLines.every((feature) => feature.properties.color === "white" && feature.properties.pattern === "solid" && feature.properties.effective_style === "white-solid" && feature.properties.dash_gap_m === 0));
const doubleCenterOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "center-double-yellow", kind: "center-line-style", segmentId: target.segmentId, color: "yellow", pattern: "solid", double: true }] }, model);
const doubleCenterLines = compileGeometry(model, doubleCenterOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId);
assert.equal(doubleCenterLines.length, styledCenterLines.length * 2);
assert.ok(doubleCenterLines.every((feature) => feature.properties.double === true && feature.properties.effective_style === "double-yellow-solid"));
assert.equal(new Set(doubleCenterLines.map((feature) => feature.properties.dash_index)).size, styledCenterLines.length);
for (const dashIndex of new Set(doubleCenterLines.map((feature) => feature.properties.dash_index))) {
const pair = doubleCenterLines.filter((feature) => feature.properties.dash_index === dashIndex);
const centers = pair.map((feature) => feature.geometry.coordinates[0].slice(0, 4).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]));
assert.ok(Math.hypot((centers[0][0] - centers[1][0]) * 96400, (centers[0][1] - centers[1][1]) * 111320) > .25);
}
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-double-center", kind: "center-line-style", segmentId: target.segmentId, color: "white", pattern: "solid", double: true }] }, model), /Invalid center line style override/);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-center", kind: "center-line-style", segmentId: target.segmentId, color: "blue", pattern: "solid" }] }, model), /Invalid center line style override/);
const edgeLine = geometry.edgeLines.features[0];
assert.ok(edgeLine && edgeLine.properties.effective_style === "white-solid");
const twoWayEdgeLines = geometry.edgeLines.features.filter((feature) => feature.properties.road_id.includes("way/10"));
assert.ok(twoWayEdgeLines.length > 0 && twoWayEdgeLines.every((feature) => feature.properties.side === "right"));
const edgeOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "edge-yellow-dashed", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: edgeLine.properties.side, color: "yellow", pattern: "dashed" }] }, model);
const styledEdgeLines = compileGeometry(model, edgeOverride).edgeLines.features.filter((feature) => feature.properties.road_id === edgeLine.properties.road_id && feature.properties.side === edgeLine.properties.side);
assert.ok(styledEdgeLines.length > 1 && styledEdgeLines.every((feature) => feature.properties.effective_style === "yellow-dashed"));
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-edge", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: "middle", color: "yellow", pattern: "solid" }] }, model), /Invalid edge line style override/);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "missing-edge-road", kind: "edge-line-style", roadId: "road:way/missing:forward", side: "left", color: "yellow", pattern: "solid" }] }, model), /Invalid edge line style override/);
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
assert.ok(geometry.connectors.features.length > 0);
assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13));
assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id));
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/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"));
// Complex-junction candidate detection: two junction nodes joined by a short
// link are one physical intersection; the same pair pushed far apart is a
// corridor and must not be reported. Detection is advisory only, so the
// geometry must be identical either way.
const candidateOsm = (lonB) => `<osm><node id="1" lon="113.999" lat="30"/><node id="2" lon="114" lat="30"/><node id="3" lon="${lonB}" lat="30"/><node id="4" lon="114.01" lat="30"/><node id="5" lon="114" lat="30.001"/><node id="6" lon="${lonB}" lat="29.999"/><way id="70"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/></way><way id="71"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/></way><way id="72"><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="73"><nd ref="2"/><nd ref="5"/><tag k="highway" v="residential"/></way><way id="74"><nd ref="3"/><nd ref="6"/><tag k="highway" v="residential"/></way></osm>`;
const nearGeometry = compileGeometry(compileRoadModel(candidateOsm("114.0002"), empty));
const nearCandidates = nearGeometry.diagnostics.filter((item) => item.rule === "complex-junction-candidate");
assert.equal(nearCandidates.length, 1);
assert.deepEqual(nearCandidates[0].suggestedCluster.nodeIds, ["2", "3"]);
assert.equal(nearCandidates[0].suggestedCluster.template, "complex-junction-v1");
assert.equal(nearCandidates[0].severity, "info");
assert.ok(nearCandidates[0].suggestedCluster.diameterMeters > 0 && nearCandidates[0].suggestedCluster.coreRadiusMeters >= 12);
// Same topology, nodes 2 and 3 now ~965 m apart: no cluster, and no template.
const farGeometry = compileGeometry(compileRoadModel(candidateOsm("114.01"), empty));
assert.equal(farGeometry.diagnostics.filter((item) => item.rule === "complex-junction-candidate").length, 0);
assert.ok(nearGeometry.intersectionSurface.features.every((feature) => feature.properties.template === null));
assert.ok(nearGeometry.roadSurface.features.every((feature) => !feature.properties.cluster_id));
const controlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00080" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><node id="4" lon="114.002" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="unmarked"/></node><node id="5" lon="114.0035" lat="30"><tag k="highway" v="crossing"/></node><node id="6" lon="114.004" lat="30"/><node id="7" lon="114.001" lat="30.001"/><way id="60"><nd ref="1"/><nd ref="2"/><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="61"><nd ref="5"/><nd ref="6"/><tag k="highway" v="footway"/></way><way id="62"><nd ref="3"/><nd ref="7"/><tag k="highway" v="residential"/></way></osm>`;
const controlGeometry = compileGeometry(compileRoadModel(controlOsm, empty));
assert.equal(controlGeometry.crosswalks.features.length, 6);
assert.equal(controlGeometry.vehicleStopLines.features.length, 1);
assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-crosswalk/v1"));
assert.ok(controlGeometry.vehicleStopLines.features.every((feature) => feature.properties.crossing_node_id === "2" && feature.properties.provenance === "native-road-stop-line/v1"));
assert.ok(controlGeometry.crosswalks.features.every((feature) => feature.properties.junction_inset_m > 0));
assert.equal(controlGeometry.vehicleStopLines.features[0].properties.junction_inset_m, controlGeometry.crosswalks.features[0].properties.junction_inset_m);
assert.ok(controlGeometry.diagnostics.some((item) => item.rule === "crossing-no-native-lane" && item.sourceIds.includes("5")));
assert.ok(controlGeometry.centerLines.features.length > 0);
assert.ok(controlGeometry.centerLines.features.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0]))));
const solidControlLines = compileGeometry(compileRoadModel(controlOsm, empty), { schema: "native-road-overrides/v1", overrides: [{ id: "solid-control", kind: "center-line-style", segmentId: controlGeometry.centerLines.features[0].properties.segment_id, color: "yellow", pattern: "solid" }] }).centerLines.features;
assert.ok(solidControlLines.every((dash) => ![...controlGeometry.crosswalks.features, ...controlGeometry.vehicleStopLines.features].some((control) => ringsOverlap(dash.geometry.coordinates[0], control.geometry.coordinates[0]))));
const arrowControlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00095" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><way id="63"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/><tag k="lanes" v="1"/><tag k="turn:lanes" v="through"/></way></osm>`;
const arrowControlGeometry = compileGeometry(compileRoadModel(arrowControlOsm, empty));
assert.ok(arrowControlGeometry.turnArrows.features.length > 0);
assert.ok(arrowControlGeometry.turnArrows.features.every((feature) => feature.properties.placement_distance_meters > 6));
assert.equal(arrowControlGeometry.centerLines.features.length, 0);
const serviceCenterLineOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><way id="64"><nd ref="1"/><nd ref="2"/><tag k="highway" v="service"/></way></osm>`;
assert.equal(compileGeometry(compileRoadModel(serviceCenterLineOsm, empty)).centerLines.features.length, 0);
const crossOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><node id="5" lon="114.001" lat="29.999"/><way id="40"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="41"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="42"><nd ref="5"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="43"><nd ref="2"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
const crossCenter = [114.001, 30];
const crossGeometry = compileGeometry(compileRoadModel(crossOsm, empty));
assert.equal(crossGeometry.intersectionSurface.features.length, 1);
assert.equal(crossGeometry.intersectionSurface.features[0].properties.boundary_mode, "rounded-approach-envelope");
assert.ok(crossGeometry.intersectionSurface.features[0].geometry.coordinates[0].length > 9);
const crossBoundary = crossGeometry.intersectionSurface.features[0].geometry.coordinates[0];
const crossRadius = (point) => Math.hypot((point[0] - crossCenter[0]) * 96400, (point[1] - crossCenter[1]) * 111320);
// The sampled tangent arc must cut inward from its old straight chord; an
// outward-bowed control point leaks asphalt into the pedestrian corner.
const firstCurveEnd = crossBoundary[8];
assert.ok(crossRadius(crossBoundary[4]) < crossRadius([(crossBoundary[0][0] + firstCurveEnd[0]) / 2, (crossBoundary[0][1] + firstCurveEnd[1]) / 2]));
assert.equal(crossGeometry.turnArrows.features.length, 0);
assert.ok(crossGeometry.directionArrows.features.length > 0);
assert.ok(crossGeometry.directionArrows.features.every((feature) => feature.properties.maneuver === "through" && feature.properties.provenance === "native-road-direction-arrow/v1"));
// Approach asphalt ends at the shared cutback; the rounded junction surface
// exclusively owns the central road area so its boundary remains visible.
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.
// The legacy wedge had five closing-ring points; two curved edges need more.
assert.ok(crossSidewalkCorners.every((feature) => feature.geometry.coordinates[0].length > 9));
assert.ok(crossSidewalkCorners.every((feature) => {
const ring = feature.geometry.coordinates[0];
const outerStart = ring[1];
const outerCurvePoint = ring[2];
const outerEnd = ring[(ring.length - 1) / 2];
const twiceArea = (outerEnd[0] - outerStart[0]) * (outerCurvePoint[1] - outerStart[1]) - (outerEnd[1] - outerStart[1]) * (outerCurvePoint[0] - outerStart[0]);
return Math.abs(twiceArea) > 1e-12;
}));
assert.ok(crossSidewalkCorners.every((feature) => {
const ring = feature.geometry.coordinates[0];
const curbStart = ring[10];
const curbCurvePoint = ring[11];
const curbEnd = ring[0];
const twiceArea = (curbEnd[0] - curbStart[0]) * (curbCurvePoint[1] - curbStart[1]) - (curbEnd[1] - curbStart[1]) * (curbCurvePoint[0] - curbStart[0]);
return Math.abs(twiceArea) > 1e-12;
}));
const sharedInteriorNodeOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.002" lat="30"/><node id="4" lon="114.001" lat="30.001"/><way id="50"><nd ref="1"/><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way><way id="51"><nd ref="4"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="sidewalk" v="both"/></way></osm>`;
const sharedInteriorModel = compileRoadModel(sharedInteriorNodeOsm, empty);
assert.equal(sharedInteriorModel.roads.length, 6);
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/1:forward"));
assert.ok(sharedInteriorModel.roads.some((road) => road.id === "road:way/50:segment/2:forward"));
const sharedInteriorGeometry = compileGeometry(sharedInteriorModel);
assert.equal(sharedInteriorGeometry.intersectionSurface.features.length, 1);
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.osm_node_id, "2");
assert.equal(sharedInteriorGeometry.intersectionSurface.features[0].properties.kind, "t");
assert.ok(sharedInteriorGeometry.connectors.features.length >= 4);
const throughConnector = sharedInteriorGeometry.connectors.features.find((feature) => feature.properties.turn === "through");
assert.ok(throughConnector, "T junction emits a through connector");
const throughCoordinates = throughConnector.geometry.coordinates;
const throughStart = throughCoordinates[0]; const throughEnd = throughCoordinates.at(-1);
for (const point of throughCoordinates.slice(1, -1)) {
const area = Math.abs((throughEnd[0] - throughStart[0]) * (point[1] - throughStart[1]) - (throughEnd[1] - throughStart[1]) * (point[0] - throughStart[0]));
assert.ok(area < 1e-12, "through connector stays on its lane-to-lane chord instead of bending through the junction node");
}
assert.ok(sharedInteriorGeometry.sidewalkSurface.features.some((feature) => feature.properties.kind === "continuation" && /segment:way\/50\/1:.*->segment:way\/50\/2:/.test(feature.properties.native_id)));
const connection = initial.connections[0];
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
const connectionOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "disconnect", kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled: false }] }, initial);
assert.equal(validateOverrides(connectionOverrides).overrides.length, 1);
assert.equal(compileRoadModel(osm, connectionOverrides).connections.find((item) => item.id === connection.id).enabled, false);
assert.ok(compileGeometry(compileRoadModel(osm, connectionOverrides)).connectors.features.length < geometry.connectors.features.length);
const disconnectedOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.00105" lat="30"/><node id="4" lon="114.002" lat="30"/><way id="20"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way><way id="21"><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
const disconnected = compileRoadModel(disconnectedOsm, empty);
const from = disconnected.endpoints.find((endpoint) => endpoint.roadId === "road:way/20:forward" && endpoint.side === "end");
const to = disconnected.endpoints.find((endpoint) => endpoint.roadId === "road:way/21:forward" && endpoint.side === "start");
const manualOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "manual", kind: "junction-connection", fromEndpointId: from.id, toEndpointId: to.id, enabled: true }] }, disconnected);
assert.ok(compileRoadModel(disconnectedOsm, manualOverrides).connections.some((item) => item.fromEndpointId === from.id && item.toEndpointId === to.id));
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "same-way", kind: "junction-connection", fromEndpointId: initial.endpoints.find((endpoint) => endpoint.roadId === "road:way/10:forward" && endpoint.side === "end").id, toEndpointId: initial.endpoints.find((endpoint) => endpoint.roadId === "road:way/10:backward" && endpoint.side === "start").id, enabled: true }] }, initial), /manual junction connection/);
const turnOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="30"><nd ref="1"/><nd ref="2"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/><tag k="turn:lanes" v="left|through|right"/></way><way id="31"><nd ref="2"/><nd ref="3"/><tag k="highway" v="primary"/><tag k="oneway" v="yes"/><tag k="lanes" v="3"/></way></osm>`;
const turnModel = compileRoadModel(turnOsm, empty);
const turnGeometry = compileGeometry(turnModel);
assert.equal(turnGeometry.sidewalkSurface.features.length, 0);
assert.ok(turnGeometry.directionArrows.features.length > 0);
assert.ok(turnGeometry.turnArrows.features.length > 0);
assert.ok(turnGeometry.turnArrows.features.every((feature) => feature.properties.provenance === "native-road-turn-arrow/v1"));
const sidewalkOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "add-sidewalk", kind: "road", roadId: "road:way/30:forward", sidewalkLeft: true }] }, turnModel);
assert.ok(compileGeometry(compileRoadModel(turnOsm, sidewalkOverride)).sidewalkSurface.features.some((feature) => feature.properties.native_id === "sidewalk:way/30:left"));
assert.equal(turnGeometry.connectors.features.length, 1);
assert.match(turnGeometry.connectors.features[0].properties.from_lane_id, /road:way\/30:forward:1$/);
assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/);
const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "block-left", kind: "lane-connection", fromLaneId: turnGeometry.connectors.features[0].properties.from_lane_id, toLaneId: turnGeometry.connectors.features[0].properties.to_lane_id, enabled: false }] }, turnModel);
assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0);
assert.equal(compileGeometry(turnModel, laneOverrides).movements.length, 0);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
// Editing OSM retires ids. Saving such a reference is still an error, but a
// consumer may ask to skip it so one stale entry cannot block the whole compile.
const staleDoc = { schema: "native-road-overrides/v1", overrides: [
{ id: "stale-road", kind: "road", roadId: "road:way/does-not-exist:forward", widthMeters: 4 },
{ id: "live-road", kind: "road", roadId: target.id, widthMeters: 9 },
] };
assert.throws(() => validateOverrides(staleDoc, initial), /Unknown road/);
const skipped = validateOverrides(staleDoc, initial, { skipStaleTargets: true });
assert.equal(skipped.overrides.length, 1);
assert.equal(skipped.overrides[0].id, "live-road");
assert.deepEqual(skipped.stale.map((item) => item.id), ["stale-road"]);
assert.equal(skipped.stale[0].target, "road:way/does-not-exist:forward");
// Skipping only forgives missing targets; a malformed entry still throws.
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-width", kind: "road", roadId: target.id, widthMeters: -1 }] }, initial, { skipStaleTargets: true }), /Invalid road override/);
const freshArea = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-fresh-area-"));
try {
const input = path.join(freshArea, "input.osm");
const outputRoot = path.join(freshArea, "outputs");
const config = path.join(freshArea, "area.json");
fs.writeFileSync(input, osm);
fs.writeFileSync(config, JSON.stringify({ id: "fresh", input, outputRoot }));
const compiledArea = compileArea(config);
assert.equal(compiledArea.result.areaId, "fresh");
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
const centerLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "center_lines.geojson"), "utf8"));
const edgeLineLayer = JSON.parse(fs.readFileSync(path.join(outputRoot, "fresh", "native-road", "layers", "edge_lines.geojson"), "utf8"));
assert.equal(centerLineLayer.type, "FeatureCollection");
assert.equal(centerLineLayer.features.length, compiledArea.comparison.nativeCenterLineFeatures);
assert.equal(edgeLineLayer.features.length, 0);
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
assert.equal(compiledArea.comparison.nativeCrosswalkFeatures, 0);
assert.equal(compiledArea.comparison.nativeVehicleStopLineFeatures, 0);
assert.equal(compiledArea.comparison.nativeApproachEnvelopeJunctions + compiledArea.comparison.nativeFallbackJunctions, compiledArea.comparison.nativeJunctionSurfaceFeatures);
assert.ok(compiledArea.comparison.nativeMaxJunctionExpansionRatio >= 0);
assert.equal(checkArea(config).ok, true);
} finally {
fs.rmSync(freshArea, { recursive: true, force: true });
}
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);
return !(a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]);
}
console.log("native road tests passed");