From df81a22ce8571dbb1dadf52bfe7e6c7aae9e98e5 Mon Sep 17 00:00:00 2001 From: que01 Date: Fri, 14 Aug 2026 09:09:48 +0800 Subject: [PATCH] feat: compile native road lanes and junctions --- scripts/compile-native-roads.js | 4 +- scripts/lib/native-road.js | 245 +++++++++++++++++++++++++++++++- scripts/road-workbench.js | 2 +- scripts/test-native-road.js | 18 +++ scripts/workbench/app.js | 28 ++++ scripts/workbench/index.html | 2 +- 6 files changed, 289 insertions(+), 10 deletions(-) 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 = ``; +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)); +const turnOsm = ``; +const turnModel = compileRoadModel(turnOsm, empty); +const turnGeometry = compileGeometry(turnModel); +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$/); assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/); console.log("native road tests passed"); diff --git a/scripts/workbench/app.js b/scripts/workbench/app.js index 229b2d4..c7f2eea 100644 --- a/scripts/workbench/app.js +++ b/scripts/workbench/app.js @@ -14,6 +14,7 @@ const saveButton = document.querySelector("#save"); const compileButton = document.querySelector("#compile"); const diagnostics = document.querySelector("#diagnostics"); const directionSwitch = document.querySelector("#direction-switch"); +const addConnectionButton = document.querySelector("#add-connection"); let state; let selected = null; let staged = []; @@ -24,6 +25,7 @@ function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; } function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); } function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); } +function layerLine(feature, stroke, width, dashed) { const points = feature.geometry?.coordinates; if (!points?.length) return; context.save(); context.setLineDash(dashed ? [5 * devicePixelRatio, 4 * devicePixelRatio] : []); context.beginPath(); points.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.strokeStyle = stroke; context.lineWidth = width * devicePixelRatio; context.stroke(); context.restore(); } function draw() { if (!state) return; context.clearRect(0, 0, canvas.width, canvas.height); @@ -31,6 +33,14 @@ function draw() { for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55"); for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666"); for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066"); + for (const feature of layers.laneCenterlines?.features || []) { + const active = feature.properties.road_id === selected?.id; + layerLine(feature, active ? "#006e91" : "#f5f6ee", active ? 2.4 : 1.2, true); + } + for (const feature of layers.connectors?.features || []) { + const active = feature.properties.from_lane_id.startsWith(`lane:${selected?.id}:`); + layerLine(feature, active ? "#d1226f" : "#ad3a76", active ? 3 : 1.8, true); + } for (const road of state.compiled.model.roads) { context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630"; @@ -78,6 +88,8 @@ function renderDirectionSwitch(road) { function renderConnections(road) { const box = document.querySelector("#connections"); box.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); + addConnectionButton.hidden = !endpoint; + addConnectionButton.onclick = () => renderAdditionalConnections(road, endpoint); const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id); if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; } const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro); @@ -93,6 +105,21 @@ function renderConnections(road) { } } +function renderAdditionalConnections(road, endpoint) { + const box = document.querySelector("#connections"); + const existing = new Set(state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint.id).map((connection) => connection.toEndpointId)); + const candidates = state.compiled.model.endpoints.filter((item) => item.side === "start" && item.roadId !== road.id && !existing.has(item.id) && endpointDistanceMeters(endpoint, item) <= 35); + box.innerHTML = ""; + if (!candidates.length) { box.textContent = "附近 35 米内没有可新增的驶出道路。请检查 OSM 路口节点,或选择另一条道路。"; return; } + const intro = document.createElement("p"); intro.textContent = "选择要新增允许驶入的道路:"; box.append(intro); + for (const targetEndpoint of candidates) { + const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint.roadId); + const button = document.createElement("button"); button.type = "button"; button.textContent = `${turnName(road, target)}:${roadLabel(target)}(相距 ${endpointDistanceMeters(endpoint, targetEndpoint).toFixed(1)} 米)`; + button.onclick = () => { stageConnection({ id: `manual:${endpoint.id}:${targetEndpoint.id}`, fromEndpointId: endpoint.id, toEndpointId: targetEndpoint.id }, true); renderConnections(road); }; + box.append(button); + } +} + function turnName(from, to) { const a = heading(from.centerline.at(-2), from.centerline.at(-1)); const b = heading(to.centerline[0], to.centerline[1]); @@ -102,6 +129,7 @@ function turnName(from, to) { return delta > 0 ? "右转" : "左转"; } function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; } +function endpointDistanceMeters(a, b) { const dx = (a.coordinate[0] - b.coordinate[0]) * 111320 * Math.cos(a.coordinate[1] * Math.PI / 180); const dy = (a.coordinate[1] - b.coordinate[1]) * 111320; return Math.hypot(dx, dy); } function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); } function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); } function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; } diff --git a/scripts/workbench/index.html b/scripts/workbench/index.html index 587102e..279c620 100644 --- a/scripts/workbench/index.html +++ b/scripts/workbench/index.html @@ -1,4 +1,4 @@ 道路编译工作台
道路编译工作台
-
osm2streets 参考面 自研道路面 道路中心线 已识别路口 待检查点
+
osm2streets 参考面 自研道路面 车道中心线 转向路径 已识别路口 待检查点