diff --git a/scripts/compile-native-roads.js b/scripts/compile-native-roads.js
index feb8516..9fe4f95 100644
--- a/scripts/compile-native-roads.js
+++ b/scripts/compile-native-roads.js
@@ -32,7 +32,7 @@ function compileArea(configPath) {
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
diagnostics: compiled.diagnostics,
- layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson" },
+ layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", connectors: "layers/connectors.geojson" },
};
const comparison = compareOsm2Streets(area, result.model.roads.length);
writeJsonAtomic(path.join(staging, "compiled.json"), result);
@@ -40,6 +40,8 @@ function compileArea(configPath) {
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
+ writeJsonAtomic(path.join(staging, "layers", "lane_centerlines.geojson"), compiled.laneCenterlines);
+ writeJsonAtomic(path.join(staging, "layers", "connectors.geojson"), compiled.connectors);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir);
return { area, result, comparison };
diff --git a/scripts/lib/native-road.js b/scripts/lib/native-road.js
index 4feb5c1..d70fca5 100644
--- a/scripts/lib/native-road.js
+++ b/scripts/lib/native-road.js
@@ -123,6 +123,7 @@ function validateOverrides(value, model) {
for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`);
} else if (item.kind === "junction-connection") {
if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override.");
+ if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId)) throw new Error("A manual junction connection must go from a road end to a nearby road start (within 35m).");
} else throw new Error(`Unsupported override kind: ${item.kind}`);
}
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
@@ -144,14 +145,42 @@ function resolveConnections(endpoints, byNode, overrides, diagnostics) {
const departures = items.filter((endpoint) => endpoint.side === "start");
for (const arrival of arrivals) for (const departure of departures) {
if (arrival.roadId === departure.roadId) continue;
+ const arrivalRoad = endpoints.find((endpoint) => endpoint.id === arrival.id)?.roadId;
+ const departureRoad = endpoints.find((endpoint) => endpoint.id === departure.id)?.roadId;
+ if (sameOsmWay(endpoints, arrivalRoad, departureRoad)) continue;
const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id);
result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" });
}
if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate));
}
+ // Overrides can add a deliberate movement omitted by the initial inference.
+ // Keep it only when both endpoints still belong to the same OSM junction.
+ for (const override of overrides.overrides.filter((item) => item.kind === "junction-connection")) {
+ const exists = result.some((connection) => connection.fromEndpointId === override.fromEndpointId && connection.toEndpointId === override.toEndpointId);
+ if (exists) continue;
+ const from = endpoints.find((endpoint) => endpoint.id === override.fromEndpointId);
+ const to = endpoints.find((endpoint) => endpoint.id === override.toEndpointId);
+ if (!from || !to || !connectionEndpointsCompatible({ endpoints }, from.id, to.id)) continue;
+ result.push({ id: `connection:${from.id}:${to.id}`, nodeId: from.nodeId, fromEndpointId: from.id, toEndpointId: to.id, enabled: override.enabled, provenance: `override:${override.id}` });
+ }
return result;
}
+function endpointNode(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId; }
+function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
+ const wayId = (roadId) => roadId.split(":")[1];
+ return wayId(firstRoadId) === wayId(secondRoadId);
+}
+function connectionEndpointsCompatible(model, fromId, toId) {
+ const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
+ const to = model.endpoints.find((endpoint) => endpoint.id === toId);
+ if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false;
+ if (from.nodeId === to.nodeId) return true;
+ const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180);
+ const dy = (from.coordinate[1] - to.coordinate[1]) * 111320;
+ return Math.hypot(dx, dy) <= 35;
+}
+
function compileGeometry(model) {
const diagnostics = [...model.diagnostics];
const features = [];
@@ -166,11 +195,148 @@ function compileGeometry(model) {
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; }
features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } });
}
- const junctionFeatures = ordinaryJunctionFeatures(model, diagnostics);
- return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, diagnostics };
+ const lanes = compileLaneCenterlines(model, diagnostics);
+ const connectors = compileConnectors(model, lanes, diagnostics);
+ const junctionFeatures = compileJunctionSurfaces(model, lanes, connectors, diagnostics);
+ validateConnectorContainment(connectors, junctionFeatures, diagnostics);
+ return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectors }, diagnostics };
}
-function ordinaryJunctionFeatures(model, diagnostics) {
+function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
+ const junctionByNode = new Map(junctionFeatures.map((feature) => [feature.properties.osm_node_id, feature]));
+ for (const connector of connectors) {
+ const junction = junctionByNode.get(connector.properties.node_id);
+ if (!junction) continue;
+ const ring = junction.geometry.coordinates[0];
+ if (!connector.geometry.coordinates.every((point) => pointInPolygon(point, ring))) {
+ diagnostics.push(diagnostic("warning", connector.properties.connection_id, [connector.properties.node_id], "connector-outside-junction", "转向路径有部分落在路口面外,请检查道路截面或转向连接。", connector.geometry.coordinates[0]));
+ }
+ }
+}
+
+function pointInPolygon(point, ring) {
+ for (let index = 1; index < ring.length; index += 1) if (pointOnSegment(point, ring[index - 1], ring[index])) return true;
+ let inside = false;
+ for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
+ const a = ring[index]; const b = ring[previous];
+ const intersect = a[1] > point[1] !== b[1] > point[1] && point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0];
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+function pointOnSegment(point, a, b) {
+ const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
+ if (Math.abs(cross) > 1e-12) return false;
+ return point[0] >= Math.min(a[0], b[0]) - 1e-12 && point[0] <= Math.max(a[0], b[0]) + 1e-12 && point[1] >= Math.min(a[1], b[1]) - 1e-12 && point[1] <= Math.max(a[1], b[1]) + 1e-12;
+}
+
+function compileLaneCenterlines(model, diagnostics) {
+ const features = [];
+ const byRoadId = new Map();
+ for (const road of model.roads) {
+ const lanes = [];
+ const laneWidth = road.widthMeters / road.laneCount;
+ const siblings = model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
+ const opposite = siblings.find((item) => item.id !== road.id);
+ // OSM centerline is the shared carriageway center. On a two-way road,
+ // offset each directed carriageway to its own side before placing lanes.
+ const carriagewayOffset = opposite ? (road.direction === "forward" ? -opposite.widthMeters / 2 : -road.widthMeters / 2) : 0;
+ for (let index = 0; index < road.laneCount; index += 1) {
+ // OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
+ // driver's left so tag positions and generated lane IDs have one meaning.
+ const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
+ const coordinates = offsetLine(road.centerline, offset);
+ if (!coordinates) { 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 } });
+ }
+ byRoadId.set(road.id, lanes);
+ }
+ return { features, byRoadId };
+}
+
+function compileConnectors(model, lanes, diagnostics) {
+ const features = [];
+ for (const connection of model.connections.filter((item) => item.enabled)) {
+ const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
+ const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
+ const fromLanes = lanes.byRoadId.get(fromRoad?.id) || [];
+ const toLanes = lanes.byRoadId.get(endpointRoadId(model, connection.toEndpointId)) || [];
+ if (!fromLanes.length || !toLanes.length) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-missing-lane", "转向连接缺少可用车道中心线。", endpointCoordinate(model, connection.fromEndpointId))); continue; }
+ for (let index = 0; index < fromLanes.length; index += 1) {
+ const turn = connectionTurn(fromRoad, toRoad);
+ if (!laneAllowsTurn(fromRoad, index, turn)) continue;
+ const targetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length);
+ const from = fromLanes[index].coordinates.at(-1); const to = toLanes[targetIndex].coordinates[0];
+ const control = connectorControlPoint(model, connection, from, to);
+ const coordinates = quadraticCurve(from, control, to, 12);
+ const length = lineLengthMeters(coordinates);
+ if (length < 0.4) continue;
+ if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,已跳过;请检查路口拓扑或人工连接。", from)); continue; }
+ features.push({ type: "Feature", properties: { native_id: `connector:${connection.id}:lane/${index + 1}`, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: fromLanes[index].id, to_lane_id: toLanes[targetIndex].id, turn, provenance: connection.provenance }, geometry: { type: "LineString", coordinates } });
+ }
+ }
+ return features;
+}
+
+function endpointRoadId(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId; }
+function endpointCoordinate(model, endpointId) { return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate; }
+function connectionTurn(fromRoad, toRoad) {
+ if (!fromRoad || !toRoad) return "unknown";
+ const incoming = headingDegrees(fromRoad.centerline.at(-2), fromRoad.centerline.at(-1));
+ const outgoing = headingDegrees(toRoad.centerline[0], toRoad.centerline[1]);
+ const delta = ((outgoing - incoming + 540) % 360) - 180;
+ if (Math.abs(delta) >= 150) return "uturn";
+ if (Math.abs(delta) <= 30) return "through";
+ return delta > 0 ? "right" : "left";
+}
+function laneAllowsTurn(road, zeroIndex, turn) {
+ if (!road) return true;
+ const tag = road.tags[`turn:lanes:${road.direction}`] ?? road.tags["turn:lanes"];
+ if (!tag) return true;
+ const lanes = String(tag).split("|").map((lane) => lane.split(";").map((value) => value.trim().replace("slight_", "")).filter(Boolean));
+ const allowed = lanes[zeroIndex];
+ return !allowed || allowed.includes(turn) || turn === "uturn" && allowed.includes("reverse");
+}
+function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
+ if (turn === "left") return 0;
+ if (turn === "right") return targetCount - 1;
+ if (turn === "uturn") return 0;
+ return Math.min(targetCount - 1, Math.round(sourceIndex / Math.max(1, sourceCount - 1) * Math.max(0, targetCount - 1)));
+}
+function connectorControlPoint(model, connection, from, to) {
+ const node = endpointCoordinate(model, connection.fromEndpointId);
+ if (!node) return [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
+ // Nearby manual joins may not share exactly the same point. The midpoint
+ // keeps their curve smooth without rewriting the authoritative OSM geometry.
+ return node;
+}
+
+function quadraticCurve(a, control, b, segments) {
+ const result = [];
+ for (let index = 0; index <= segments; index += 1) {
+ const t = index / segments; const u = 1 - t;
+ result.push([u * u * a[0] + 2 * u * t * control[0] + t * t * b[0], u * u * a[1] + 2 * u * t * control[1] + t * t * b[1]]);
+ }
+ return result;
+}
+
+function offsetLine(line, offsetMeters) {
+ if (line.length < 2) return null;
+ const origin = line[0]; const points = line.map((point) => project(point, origin)); const result = [];
+ for (let index = 0; index < points.length; index += 1) {
+ const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)];
+ const dx = next[0] - previous[0]; const dy = next[1] - previous[1]; const length = Math.hypot(dx, dy);
+ if (length < 0.01) return null;
+ result.push(unproject([points[index][0] - dy / length * offsetMeters, points[index][1] + dx / length * offsetMeters], origin));
+ }
+ return result;
+}
+
+function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
+
+function compileJunctionSurfaces(model, lanes, connectors, diagnostics) {
const byNode = new Map();
for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
@@ -180,15 +346,80 @@ function ordinaryJunctionFeatures(model, diagnostics) {
for (const [nodeId, endpoints] of byNode) {
const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1]));
if (wayIds.size < 3 || wayIds.size > 4) continue;
+ const node = endpoints[0].coordinate;
const roads = endpoints.map((endpoint) => model.roads.find((road) => road.id === endpoint.roadId));
- const radius = Math.max(...roads.map((road) => road.widthMeters)) * 0.65;
- const ring = circleRing(endpoints[0].coordinate, radius, 16);
- result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), rule: "ordinary-junction-disc/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
- diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "Generated a conservative ordinary junction surface; connector geometry is deferred.", endpoints[0].coordinate));
+ const cutbackMeters = Math.max(...roads.map((road) => road.widthMeters)) * 1.4;
+ const boundary = junctionBoundary(model, endpoints, node, cutbackMeters);
+ const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
+ if (boundary.length < 3 || !junctionConnectors.length) {
+ diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
+ continue;
+ }
+ const envelope = convexHull([...boundary, ...junctionConnectors.flatMap((feature) => feature.geometry.coordinates)]);
+ const ring = [...envelope, envelope[0]];
+ if (hasSelfIntersection(ring)) {
+ diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
+ continue;
+ }
+ result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), cutback_m: cutbackMeters, connector_count: junctionConnectors.length, rule: "junction-cutback-envelope/v1" }, geometry: { type: "Polygon", coordinates: [ring] } });
+ diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
}
return result;
}
+function junctionBoundary(model, endpoints, node, cutbackMeters) {
+ const points = [];
+ for (const endpoint of endpoints) {
+ const road = model.roads.find((item) => item.id === endpoint.roadId);
+ if (!road) continue;
+ const line = endpoint.side === "end" ? [...road.centerline].reverse() : road.centerline;
+ const cutback = pointAlongLine(line, cutbackMeters);
+ if (!cutback) continue;
+ const heading = headingAtEndpoint(line);
+ const half = road.widthMeters / 2;
+ points.push(offsetCoordinate(cutback, heading + 90, half));
+ points.push(offsetCoordinate(cutback, heading - 90, half));
+ }
+ return sortAround(node, points);
+}
+
+function pointAlongLine(line, meters) {
+ let remaining = meters;
+ for (let index = 1; index < line.length; index += 1) {
+ const length = distanceMeters(line[index - 1], line[index]);
+ if (length >= remaining) return interpolate(line[index - 1], line[index], remaining / length);
+ remaining -= length;
+ }
+ return line.at(-1);
+}
+
+function headingAtEndpoint(line) { return headingDegrees(line[0], line[1]); }
+function headingDegrees(a, b) { return Math.atan2((b[0] - a[0]) * Math.cos(a[1] * Math.PI / 180), b[1] - a[1]) * 180 / Math.PI; }
+function offsetCoordinate(point, degrees, meters) { const radians = degrees * Math.PI / 180; return [point[0] + Math.sin(radians) * meters / (111320 * Math.cos(point[1] * Math.PI / 180)), point[1] + Math.cos(radians) * meters / 111320]; }
+function sortAround(center, points) { return points.sort((a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0])); }
+function convexHull(points) {
+ const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+ if (unique.length < 3) return unique;
+ const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
+ const lower = []; for (const point of unique) { while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop(); lower.push(point); }
+ const upper = []; for (const point of [...unique].reverse()) { while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop(); upper.push(point); }
+ return [...lower.slice(0, -1), ...upper.slice(0, -1)];
+}
+function interpolate(a, b, ratio) { return [a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio]; }
+function distanceMeters(a, b) { const dx = (b[0] - a[0]) * 111320 * Math.cos(a[1] * Math.PI / 180); const dy = (b[1] - a[1]) * 111320; return Math.hypot(dx, dy); }
+function hasSelfIntersection(ring) {
+ for (let first = 0; first < ring.length - 1; first += 1) for (let second = first + 1; second < ring.length - 1; second += 1) {
+ if (Math.abs(first - second) <= 1 || first === 0 && second === ring.length - 2) continue;
+ if (segmentsIntersect(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
+ }
+ return false;
+}
+function segmentsIntersect(a, b, c, d) {
+ const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
+ const abC = cross(a, b, c); const abD = cross(a, b, d); const cdA = cross(c, d, a); const cdB = cross(c, d, b);
+ return (abC > 0 && abD < 0 || abC < 0 && abD > 0) && (cdA > 0 && cdB < 0 || cdA < 0 && cdB > 0);
+}
+
function circleRing(center, radius, segments) {
const origin = center;
const ring = [];
diff --git a/scripts/road-workbench.js b/scripts/road-workbench.js
index 30b7541..c49a61e 100644
--- a/scripts/road-workbench.js
+++ b/scripts/road-workbench.js
@@ -47,7 +47,7 @@ function handle(request, response, area, configPath) {
function state(area) {
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
- return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
+ return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
}
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
diff --git a/scripts/test-native-road.js b/scripts/test-native-road.js
index f692e2b..21df95b 100644
--- a/scripts/test-native-road.js
+++ b/scripts/test-native-road.js
@@ -18,11 +18,29 @@ assert.equal(edited.sidewalkLeft, false);
const geometry = compileGeometry(model);
assert.equal(geometry.roadSurface.features.length, 2);
assert.ok(geometry.roadSurface.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.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.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-cutback-envelope/v1"));
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 = `