fix: 修正 Cesium 巡航车道中心对齐

This commit is contained in:
2026-08-08 15:21:49 +08:00
parent aeb2cec021
commit 5658e7337d
17 changed files with 1081 additions and 192 deletions

View File

@@ -205,6 +205,28 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
模型保持在**局部 ENU 坐标系**X 东、Y 北、Z 上),靠伴生 JSON 配合
`Cesium.Transforms.eastNorthUpToFixedFrame` 摆放。
### WGS84 ENU 坐标契约
Blender 中所有经纬度几何必须通过 `osmassets.osm.Projector` 转换。该转换必须与 Cesium
`eastNorthUpToFixedFrame(anchor)` 使用同一个 WGS84 椭球语义:先将经纬度转换为
ECEF再将相对锚点的向量投影到 East/North 轴。禁止用固定 `111320 m/deg`
equirectangular等距圆柱近似生成场景坐标。
固定米/度近似只会在锚点附近碰巧重合;纬向比例与 WGS84 实际比例不同,误差会随离锚点
距离增长。表现为 Cesium Entity 路线在部分道路居中、在其他道路相对整个 GLB 路面同向
平移。只验证 route 与 GeoJSON 自洽无法发现此问题,必须重新生成
`blender,cesium,preview` 并在最终 Cesium 画面中核对。
修改 `Projector` 后至少执行:
```bash
python3 -m unittest blender.tests.test_pure
npm run build:area -- --config config/areas/<area>.json --stages blender,cesium,preview
```
`blender.tests.test_pure.ProjectorTest` 必须断言锚点为原点、East/North 方向正确,以及局部
经纬度增量符合 WGS84 椭球曲率半径。
### Cesium contract
新生成场景的 Cesium 导出调色写在 `catalog.MATERIALS[*]["cesium"]`,由
@@ -278,6 +300,7 @@ tilt_y = TILT_JITTER * math.cos(index * 0.927295)
| 直接 append vendored 资产的材质 | alpha-clip 缺失,树冠渲染成一块 |
| 删掉"试过不行"的注释 | 下一个人重新踩同一个坑 |
| 从 `scene-layers.js` 的 hex 换算 Blender 颜色 | 抹掉独立调过的配色 |
| 用固定米/度比例投影经纬度 | GLB 与 Cesium Entity 随离锚点距离产生位置漂移 |
| 加新资产不配 Cesium 调色 | Cesium 里显得发黑 |
| 靠调 `FOLIAGE_EMISSION` 提亮植被 | 用错了旋钮,该调 albedo gain |
| 在 `MATERIALS` 中间插入条目 | GLB 材质索引整体平移 |

View File

@@ -46,6 +46,7 @@ cesium-preview.js 浏览器
- [ ] 你在改任何被 `execFileSync` / `spawnSync` 调起的东西
- [ ] 你在改 stage 的 stdout 打印
- [ ] 你要新增一种在 Blender 里生成、要在 Cesium 里看的资产
- [ ] Blender GLB 与 Cesium Entity、polyline 或 label 必须在地理位置上重合
---
@@ -161,6 +162,19 @@ OSM way 的端点不一定在原始 XML 中有三个以上相连 wayosm2stree
**教训****跨阶段运行时 JSON 必须在最早消费它的 stage 从当前权威产物重建;同时要
区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。**
### 坑 7GeoJSON 内部正确,但 GLB 与 Cesium 路线仍然错位
巡航路线曾经与 osm2streets Driving polygon 中轴逐点吻合到厘米级,仍在最终预览中出现
部分路段偏离车道中心。原因是路线由 Cesium 直接按 WGS84 经纬度放置,而 Blender GLB
使用固定 `111320 m/deg` 的近似投影后再放到 WGS84 ENU 锚点。两套坐标仅在锚点附近
重合,误差随距离增长。
**教训**:跨坐标运行时不能只验证源数据内部自洽。凡是 Blender GLB 与 Cesium Entity
需要重合,必须检查 `GeoJSON -> Blender local ENU -> GLB modelMatrix`
`GeoJSON -> Cesium Cartesian3` 的端到端契约,并在最终画面做横截面对齐验证。
→ [资产生成WGS84 ENU 坐标契约](../blender/asset-generation.md#wgs84-enu-坐标契约)
---
## 加东西时的检查清单

View File

@@ -746,6 +746,9 @@ runtime 与 HTML并写 preview manifest。预览内容实现不得回流到
- `inputs.osm`
- `inputs.glb`
- `inputs.metadata`
- `inputs.lanePolygons`
- `inputs.network`
- `inputs.intersectionSurface`
- `inputs.previewCss`
- `inputs.previewJs`
- `outputs.cesiumPreview`

View File

@@ -112,6 +112,9 @@ setLoadingMessage("Preparing view")
window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras };
```
预览加载的生成式 JSON路线和交通信号使用 `fetch(..., { cache: "no-store" })`,因为
这些文件保持稳定文件名但会被单独重生成;浏览器不得继续显示旧的巡航路线。
调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。
---

View File

@@ -11,9 +11,9 @@
## 2. Signatures
```js
buildVehicleRoute(osmPath) => {
source, bounds, generatedAt, speedMetersPerSecond, loop,
routes, segments
buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath) => {
source, laneSource, networkSource, intersectionSource, bounds, generatedAt, speedMetersPerSecond, loop,
routes, segments, diagnostics
}
allowedTurns(tags, direction) => Set<"left" | "through" | "right">
@@ -28,7 +28,14 @@ classifyConnection(incomingEdge, outgoingEdge) =>
- `routes` 是当前主字段;`segments` 必须是同一数组的兼容别名,供旧预览使用。
- 每个路线至少包含 `id``coordinates``centerlineCoordinates``lengthMeters`
`maneuvers``edgeIds``coordinates` 是右侧车道偏移后的闭合巡航轨迹
`maneuvers``edgeIds``laneSegments``connectors`。道路区间来自匹配的 Driving lane polygon 中轴
- 路线拓扑以 `network.json` 的 internal road 和 intersection 为准;禁止把整个 OSM way 直接当作一条不可分割 edge。
- connector 必须绑定同一个 internal intersection并位于对应 `intersection_surface.geojson` 内或允许的边界容差内;越界时拒绝候选路线。
- preview 必须将 `lane_polygons.geojson``network.json``intersection_surface.geojson` 作为强制输入;缺失或无效时在写产物前失败。
- route 经纬度由 Cesium 按 WGS84 直接放置;最终道路 GLB 必须由 WGS84 ECEF→ENU
`Projector` 生成。禁止以固定米/度近似投影道路,否则即使 route 与 lane polygon
完全一致,最终画面仍会随离锚点距离产生横向偏移。
- 单条路线无法可靠匹配时跳过并写结构化 `diagnostics`,不得回退固定或默认车道宽度。
-`oneway=yes`(及等价真值)的 way 只能按 OSM 原始方向生成 edge绝不能生成反向
`:backward` edge`oneway=-1` 仅允许反向 edge。
- 去程在路口按入边方向读取 `turn:lanes:forward``turn:lanes:backward`,只有标签中的
@@ -36,6 +43,7 @@ classifyConnection(incomingEdge, outgoingEdge) =>
- 返程是展示路线的原路回返,不以反向 `turn:lanes` 再次否决,但依旧不可逆行单行道。
- 路网没有闭环时,在去程和返程端点插入平滑调头曲线;不得在 way 端点或路口瞬移。
- 选择菜单使用 `#编号 · 长度 m · 左 N / 右 N / 直 N`,因为一条路线可跨越多个道路名称。
- 预览只显示当前下拉框选中车辆的 route polyline避免多条闭环轨迹在路口重叠造成错误的偏移判断。
## 4. Validation & Error Matrix
@@ -62,6 +70,8 @@ classifyConnection(incomingEdge, outgoingEdge) =>
`node --check scripts/lib/cesium-preview.js`:保证 Node 与浏览器直载脚本语法可用。
- 对目标区域运行 `npm run build:area -- --config config/areas/<area>.json --stages preview`,确认
`routes` 中存在左、右、直动作,且 Cesium 下拉标签显示编号、长度与动作统计。
- 修改地理投影时必须运行 `blender,cesium,preview`,不能只重跑 preview最终检查青色路线
到黄色中心线及道路边缘的横截面距离,确认两侧路线分别位于各自车道中心。
## 7. Wrong vs Correct

View File

@@ -113,7 +113,9 @@ outputs/<area-id>/_pipeline/stages/compress.manifest.json
manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要前段记录 OSM /
GeoJSON feature countsBlender 记录 `.blend` / renderCesium/压缩记录 GLB digest
preview 记录 GLB、metadata、车辆路线和 runtime 文件。`diagnose:area` 会读取这些
preview 记录 GLB、metadata、`lane_polygons.geojson``network.json``intersection_surface.geojson`
车辆路线和 runtime 文件。巡航道路区间按 osm2streets internal road 匹配真实 Driving lane 中轴,
路口 connector 必须通过 intersection surface 越界检查。`diagnose:area` 会读取这些
manifest缺失或当前输入/输出 sha/bytes 不一致会在 `Stage manifests``Warnings`
里标出来。
@@ -224,7 +226,7 @@ python3 -m http.server 8765
## 实验:车辆巡航
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.gltf`路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。
`preview``cesium` 阶段会额外生成 `<area-id>-vehicle-route.json``<area-id>-vehicle-car.gltf`OSM 提供可行驶拓扑与转向语义,实际巡航坐标来自 osm2streets 的 `Driving` lane polygon 中轴;同向多车道按下一次 maneuver 选择兼容车道。路线中的 `laneSegments` 记录每个 polygon fragment 的 OSM way、方向、lane index、width、center offset 和来源。车道数据缺失、歧义、断裂或没有兼容转向车道时,候选路线会被跳过并写入顶层 `diagnostics`,不会回退到固定偏移或默认宽度。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。
这是用于验证高精度巡航可用性的预览层功能,不会改变 Blender/GLB 主资产本身。车辆模型是无 logo 的轻量预览模型,生成在输出目录中。

View File

@@ -149,23 +149,55 @@ def parse_height(feature_tags, default):
class Projector:
"""Equirectangular projection about the centre of the OSM bounds.
"""WGS84 ECEF to local ENU projection about the OSM bounds centre.
Output is metres in a local ENU frame (X east, Y north), which is what both
the Blender scene and the Cesium GLB are authored in.
the Blender scene and the Cesium GLB are authored in. Cesium places the
GLB with eastNorthUpToFixedFrame, so using the same ellipsoid transform is
required to keep route coordinates aligned across the whole scene.
"""
WGS84_A = 6378137.0
WGS84_E2 = 6.6943799901413165e-3
def __init__(self, bounds):
self.bounds = bounds
self.lon0 = (bounds["min_lon"] + bounds["max_lon"]) / 2
self.lat0 = (bounds["min_lat"] + bounds["max_lat"]) / 2
self.m_per_lat = 111320.0
self.m_per_lon = 111320.0 * math.cos(math.radians(self.lat0))
self._lon0_rad = math.radians(self.lon0)
self._lat0_rad = math.radians(self.lat0)
self._sin_lon0 = math.sin(self._lon0_rad)
self._cos_lon0 = math.cos(self._lon0_rad)
self._sin_lat0 = math.sin(self._lat0_rad)
self._cos_lat0 = math.cos(self._lat0_rad)
self._origin_ecef = self._ecef(self._lon0_rad, self._lat0_rad)
denominator = math.sqrt(1.0 - self.WGS84_E2 * self._sin_lat0 ** 2)
prime_vertical_radius = self.WGS84_A / denominator
meridional_radius = self.WGS84_A * (1.0 - self.WGS84_E2) / denominator ** 3
radians_per_degree = math.pi / 180.0
self.m_per_lon = prime_vertical_radius * self._cos_lat0 * radians_per_degree
self.m_per_lat = meridional_radius * radians_per_degree
def xy(self, lon_lat):
lon, lat = lon_lat
return ((lon - self.lon0) * self.m_per_lon,
(lat - self.lat0) * self.m_per_lat)
x, y, z = self._ecef(math.radians(lon), math.radians(lat))
dx = x - self._origin_ecef[0]
dy = y - self._origin_ecef[1]
dz = z - self._origin_ecef[2]
east = -self._sin_lon0 * dx + self._cos_lon0 * dy
north = (-self._sin_lat0 * self._cos_lon0 * dx
- self._sin_lat0 * self._sin_lon0 * dy
+ self._cos_lat0 * dz)
return east, north
def _ecef(self, lon_rad, lat_rad):
sin_lat = math.sin(lat_rad)
cos_lat = math.cos(lat_rad)
radius = self.WGS84_A / math.sqrt(1.0 - self.WGS84_E2 * sin_lat ** 2)
return (radius * cos_lat * math.cos(lon_rad),
radius * cos_lat * math.sin(lon_rad),
radius * (1.0 - self.WGS84_E2) * sin_lat)
def inside(self, lon_lat, pad=0.00035):
lon, lat = lon_lat

View File

@@ -238,14 +238,23 @@ class ProjectorTest(unittest.TestCase):
self.assertGreater(east, 0.0)
self.assertGreater(north, 0.0)
def test_longitude_metres_shrink_with_latitude(self):
self.assertAlmostEqual(
self.projector.m_per_lon,
111320.0 * math.cos(math.radians(30.005)),
places=6,
)
def test_wgs84_local_scale_matches_ellipsoid(self):
latitude = math.radians(30.005)
denominator = math.sqrt(1.0 - Projector.WGS84_E2 * math.sin(latitude) ** 2)
expected_lon = (Projector.WGS84_A / denominator
* math.cos(latitude) * math.pi / 180.0)
expected_lat = (Projector.WGS84_A * (1.0 - Projector.WGS84_E2)
/ denominator ** 3 * math.pi / 180.0)
self.assertAlmostEqual(self.projector.m_per_lon, expected_lon, places=6)
self.assertAlmostEqual(self.projector.m_per_lat, expected_lat, places=6)
self.assertLess(self.projector.m_per_lon, self.projector.m_per_lat)
def test_projection_matches_local_wgs84_scale(self):
east, _ = self.projector.xy((114.006, 30.005))
_, north = self.projector.xy((114.005, 30.006))
self.assertAlmostEqual(east, self.projector.m_per_lon * 0.001, places=4)
self.assertAlmostEqual(north, self.projector.m_per_lat * 0.001, places=4)
def test_inside_honours_the_pad(self):
self.assertTrue(self.projector.inside((114.005, 30.005)))
# Default pad is 0.00035 degrees, so just outside the box still counts.

View File

@@ -493,11 +493,19 @@ function writeCesiumPreview(area) {
ensureFile(area.outputs.glb, "Cesium GLB");
ensureFile(area.outputs.metadata, "Cesium metadata");
ensureFile(area.outputs.trafficSignals, "Traffic signal anchors");
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
const network = path.join(area.outputs.geojsonDir, "network.json");
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
ensureFile(lanePolygons, "Driving lane polygons");
ensureFile(network, "osm2streets network");
ensureFile(intersectionSurface, "Intersection surfaces");
// 在创建或覆盖任何 preview 产物前完成权威车道输入的解析与路线计算。
const vehicleRoute = buildPreviewVehicleRoute(area.input, lanePolygons, network, intersectionSurface);
const htmlPath = area.outputs.cesiumPreview;
const started = Date.now();
const startedAt = new Date(started).toISOString();
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
writeVehicleRoute(area);
writeVehicleRoute(area, vehicleRoute);
const vehicleModelNames = writeVehicleModel(area);
writeCesiumPreviewSupportFiles(path.dirname(htmlPath));
const glbName = path.basename(area.outputs.glb);
@@ -519,6 +527,9 @@ function writeCesiumPreview(area) {
osm: fileRecord(area.input),
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
lanePolygons: fileRecord(lanePolygons),
network: fileRecord(network),
intersectionSurface: fileRecord(intersectionSurface),
previewCss: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.css")),
previewJs: fileRecord(path.join(repoRoot, "scripts", "lib", "cesium-preview.js")),
},
@@ -543,8 +554,7 @@ function previewRelativePath(fromDir, target) {
return path.relative(fromDir, target).split(path.sep).join("/");
}
function writeVehicleRoute(area) {
const route = buildPreviewVehicleRoute(area.input);
function writeVehicleRoute(area, route) {
fs.mkdirSync(path.dirname(area.outputs.vehicleRoute), { recursive: true });
fs.writeFileSync(area.outputs.vehicleRoute, `${JSON.stringify(route, null, 2)}\n`);
console.log(`Vehicle route: ${area.outputs.vehicleRoute}`);

View File

@@ -1452,6 +1452,7 @@ from qgis.core import (
QgsApplication,
QgsCoordinateReferenceSystem,
QgsEditorWidgetSetup,
QgsFieldConstraints,
QgsFillSymbol,
QgsMarkerSymbol,
QgsMapRendererCustomPainterJob,
@@ -1481,6 +1482,11 @@ try:
except AttributeError:
IMAGE_FORMAT = QImage.Format_ARGB32_Premultiplied
try:
NOT_NULL_CONSTRAINT = QgsFieldConstraints.Constraint.ConstraintNotNull
except AttributeError:
NOT_NULL_CONSTRAINT = QgsFieldConstraints.ConstraintNotNull
def fill_symbol(color, outline="0,0,0,0", outline_width="0"):
return QgsFillSymbol.createSimple({
"color": color,
@@ -1517,7 +1523,7 @@ def make_signal_layer():
for field_name in ("signal_uid", "control_id", "approach_id", "source_way_id", "stop_lon", "stop_lat"):
index = layer.fields().indexOf(field_name)
if index >= 0:
layer.setFieldConstraint(index, 1)
layer.setFieldConstraint(index, NOT_NULL_CONSTRAINT)
form = layer.editFormConfig()
form.setReadOnly(index, True)
layer.setEditFormConfig(form)

View File

@@ -459,6 +459,9 @@ function stageManifestStatus(area, configPath = null) {
osm: area.input,
glb: area.outputs.glb,
metadata: area.outputs.metadata,
lanePolygons: path.join(area.outputs.geojsonDir, "lane_polygons.geojson"),
network: path.join(area.outputs.geojsonDir, "network.json"),
intersectionSurface: path.join(area.outputs.geojsonDir, "intersection_surface.geojson"),
previewCss: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.css"),
previewJs: path.join(path.resolve(__dirname, ".."), "lib", "cesium-preview.js"),
},

View File

@@ -67,7 +67,9 @@
}
async function fetchJson(url) {
const response = await fetch(url);
// Generated preview JSON keeps a stable filename; bypass browser caches so
// route regeneration is visible immediately during inspection.
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error("Could not load " + url + ": " + response.status);
}
@@ -350,7 +352,7 @@
setStatus(toggleScene.checked ? "Scene visible" : "Scene hidden");
});
toggleRoutes.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.routeEntity.show = toggleRoutes.checked;
syncSelectedRouteVisibility(cruise);
});
toggleVehicles.addEventListener("change", () => {
for (const vehicle of cruise.vehicles) vehicle.entity.show = toggleVehicles.checked;
@@ -483,6 +485,7 @@
});
vehicleSelect.addEventListener("change", () => {
cruise.state.selectedIndex = Number(vehicleSelect.value || 0);
syncSelectedRouteVisibility(cruise);
setStatus(selectedVehicle(cruise).label);
});
@@ -527,11 +530,19 @@
vehicleSelect.appendChild(option);
return vehicle;
});
return {
const cruise = {
vehicles,
baseSpeed: speed,
state: { selectedIndex: 0 }
};
syncSelectedRouteVisibility(cruise);
return cruise;
}
function syncSelectedRouteVisibility(cruise) {
for (let index = 0; index < cruise.vehicles.length; index += 1) {
cruise.vehicles[index].routeEntity.show = toggleRoutes.checked && index === cruise.state.selectedIndex;
}
}
function addTrafficSignals(viewer, signalData, start, assets) {

View File

@@ -0,0 +1,161 @@
"use strict";
const EARTH_RADIUS_METERS = 6371008.8;
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
if (!Array.isArray(ring) || ring.length < 5 || !sameCoordinate(ring[0], ring.at(-1))) return null;
const vertices = ring.slice(0, -1);
if (!vertices.every(validCoordinate)) return null;
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
const centerline = vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
return polylineLength(centerline) > 0.01 ? centerline : null;
}
function orientPolyline(polyline, reference) {
if (!polyline?.length || !reference?.length) return null;
const forward = projectedDistanceAlong(reference, polyline.at(-1)) - projectedDistanceAlong(reference, polyline[0]);
if (Math.abs(forward) < 0.01) return null;
return forward > 0 ? polyline.map(copyCoordinate) : [...polyline].reverse().map(copyCoordinate);
}
function stitchPolylines(polylines, maxGapMeters) {
if (!polylines.length) return null;
const result = [];
for (const polyline of polylines) {
if (!polyline?.length) return null;
if (result.length && haversineMeters(result.at(-1), polyline[0]) > maxGapMeters) return null;
appendCoordinates(result, polyline);
}
return result;
}
function projectedDistanceAlong(polyline, point) {
let traversed = 0;
let best = { distance: Infinity, along: 0, lateral: 0 };
for (let index = 1; index < polyline.length; index += 1) {
const start = polyline[index - 1];
const end = polyline[index];
const meters = metersAt((start[1] + end[1]) / 2);
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const length = Math.hypot(dx, dy);
if (length < 0.001) continue;
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
const offsetX = px - dx * ratio;
const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY);
if (distance < best.distance) {
const rightX = dy / length;
const rightY = -dx / length;
best = {
distance,
along: traversed + length * ratio,
lateral: offsetX * rightX + offsetY * rightY,
};
}
traversed += length;
}
return best.along;
}
function lateralOffsetFrom(polyline, point) {
let best = null;
for (let index = 1; index < polyline.length; index += 1) {
const start = polyline[index - 1];
const end = polyline[index];
const meters = metersAt((start[1] + end[1]) / 2);
const dx = (end[0] - start[0]) * meters.lon;
const dy = (end[1] - start[1]) * meters.lat;
const px = (point[0] - start[0]) * meters.lon;
const py = (point[1] - start[1]) * meters.lat;
const length = Math.hypot(dx, dy);
if (length < 0.001) continue;
const ratio = Math.max(0, Math.min(1, (px * dx + py * dy) / (length * length)));
const offsetX = px - dx * ratio;
const offsetY = py - dy * ratio;
const distance = Math.hypot(offsetX, offsetY);
if (!best || distance < best.distance) {
best = { distance, lateral: offsetX * dy / length - offsetY * dx / length };
}
}
return best;
}
function polylineMidpoint(polyline) {
const target = polylineLength(polyline) / 2;
let traversed = 0;
for (let index = 1; index < polyline.length; index += 1) {
const length = haversineMeters(polyline[index - 1], polyline[index]);
if (traversed + length >= target) {
const ratio = length ? (target - traversed) / length : 0;
return [
polyline[index - 1][0] + (polyline[index][0] - polyline[index - 1][0]) * ratio,
polyline[index - 1][1] + (polyline[index][1] - polyline[index - 1][1]) * ratio,
];
}
traversed += length;
}
return polyline.length ? copyCoordinate(polyline.at(-1)) : null;
}
function polylineLength(polyline) {
let total = 0;
for (let index = 1; index < (polyline?.length || 0); index += 1) {
total += haversineMeters(polyline[index - 1], polyline[index]);
}
return total;
}
function haversineMeters(a, b) {
const lat1 = degreesToRadians(a[1]);
const lat2 = degreesToRadians(b[1]);
const dLat = degreesToRadians(b[1] - a[1]);
const dLon = degreesToRadians(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
}
function appendCoordinates(target, coordinates) {
for (const coordinate of coordinates) {
if (!sameCoordinate(target.at(-1), coordinate)) target.push(copyCoordinate(coordinate));
}
}
function validCoordinate(value) {
return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]);
}
function sameCoordinate(a, b) {
return Boolean(a && b && a[0] === b[0] && a[1] === b[1]);
}
function copyCoordinate(coordinate) {
return [coordinate[0], coordinate[1]];
}
function metersAt(latitude) {
return { lon: 111320 * Math.cos(degreesToRadians(latitude)), lat: 111320 };
}
function degreesToRadians(value) {
return value * Math.PI / 180;
}
module.exports = {
appendCoordinates,
haversineMeters,
laneCenterline,
lateralOffsetFrom,
orientPolyline,
polylineLength,
polylineMidpoint,
projectedDistanceAlong,
stitchPolylines,
};

View File

@@ -2,6 +2,7 @@
const fs = require("fs");
const path = require("path");
const { laneCenterline } = require("./lane-geometry");
const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "assets", "lane-icons", "manifest.json");
const LANE_WIDTH_METERS = 3.2;
@@ -262,23 +263,6 @@ function closestPointOnSegment(point, start, end, meters) {
return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])];
}
function laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
// A straight osm2streets Driving lane is commonly a closed quadrilateral:
// four distinct vertices plus the repeated closing vertex. Its opposing
// edges still provide the same two-point centerline as longer lane shapes.
if (!ring || ring.length < 5) return null;
// osm2streets Driving polygons are ordered along one boundary then back
// along the other. Midpoints of paired vertices form the rendered lane axis.
const vertices = ring.slice(0, -1);
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
return vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
}
function axisForLane(ordered, meters) {
return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters);
}

View File

@@ -2,31 +2,120 @@
const fs = require("fs");
const { parseOsm } = require("./osm");
const {
appendCoordinates,
haversineMeters,
laneCenterline,
lateralOffsetFrom,
orientPolyline,
polylineLength,
polylineMidpoint,
} = require("./lane-geometry");
const MAX_ROUTES = 5;
const MAX_PATH_EDGES = 7;
const MIN_ROUTE_EDGES = 3;
const LANE_OFFSET_METERS = 1.3;
const MAX_LANE_DISTANCE_METERS = 20;
const MIN_LATERAL_SEPARATION_METERS = 0.25;
const JUNCTION_TRIM_METERS = 6.0;
const CONNECTOR_SURFACE_TOLERANCE_METERS = 0.35;
const ALL_TURNS = new Set(["left", "through", "right"]);
function buildVehicleRoute(osmPath) {
function buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath) {
if (!lanePolygonsPath || !networkPath || !intersectionSurfacePath) {
throw new Error("Lane polygons, osm2streets network, and intersection surface paths are required for vehicle route generation");
}
const osm = parseOsm(fs.readFileSync(osmPath, "utf8"));
const edges = directedRoadEdges(osm.ways, osm.nodes, osm.bounds);
const routes = selectRoutes(findReturnRoutes(edges));
const lanePolygons = readLanePolygons(lanePolygonsPath);
const network = readJsonObject(networkPath, "osm2streets network");
const intersectionSurfaces = readFeatureCollection(intersectionSurfacePath, "intersection surfaces");
const diagnostics = [];
const laneIndex = indexDrivingLanes(lanePolygons.features, diagnostics);
const intersections = indexIntersections(network, intersectionSurfaces.features);
const edges = directedRoadEdges(network, osm.ways, diagnostics);
const candidates = findReturnRoutes(edges);
const routes = [];
for (const candidate of candidates) {
const route = makeRoute(candidate, laneIndex, intersections, diagnostics);
if (route) routes.push(route);
}
const selected = selectRoutes(routes);
return {
source: osmPath,
laneSource: lanePolygonsPath,
networkSource: networkPath,
intersectionSource: intersectionSurfacePath,
bounds: osm.bounds,
generatedAt: new Date().toISOString(),
speedMetersPerSecond: 8.0,
loop: true,
routes,
// Older previews read `segments`; keep it as an alias while new previews
// use the more accurate route name.
segments: routes,
routes: selected,
diagnostics,
// 旧预览仍读取 segments保持与 routes 为同一个数组引用。
segments: selected,
};
}
function readLanePolygons(file) {
return readFeatureCollection(file, "lane polygons");
}
function readFeatureCollection(file, label) {
const collection = readJsonObject(file, label);
if (collection?.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
throw new Error(`Invalid ${label} GeoJSON '${file}': expected FeatureCollection`);
}
return collection;
}
function readJsonObject(file, label) {
try {
const value = JSON.parse(fs.readFileSync(file, "utf8"));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected JSON object");
return value;
} catch (error) {
throw new Error(`Invalid ${label} JSON '${file}': ${error.message}`);
}
}
function indexDrivingLanes(features, diagnostics) {
const index = new Map();
features.forEach((feature, featureIndex) => {
if (feature?.properties?.type !== "Driving") return;
const centerline = laneCenterline(feature);
const direction = feature.properties.direction;
const widthMeters = Number(feature.properties.width);
const road = Number(feature.properties.road);
if (!centerline || !["Fwd", "Back"].includes(direction) || !Number.isFinite(widthMeters) || widthMeters <= 0 || !Number.isInteger(road)) {
diagnostics.push({
reason: "invalid_lane_polygon",
featureIndex,
road: feature?.properties?.road ?? null,
laneIndex: feature?.properties?.index ?? null,
});
return;
}
const lane = {
featureIndex,
polygonId: feature.id ?? `${feature.properties.road ?? "road"}:${direction}:${feature.properties.index ?? featureIndex}`,
road,
laneIndex: feature.properties.index,
widthMeters,
allowedTurns: normalizeAllowedTurns(feature.properties.allowed_turns),
centerline,
};
const key = laneKey(road, direction);
if (!index.has(key)) index.set(key, []);
index.get(key).push(lane);
});
return index;
}
function normalizeAllowedTurns(value) {
if (!Array.isArray(value)) return new Set();
return new Set(value.map(normalizeTurn).filter(Boolean));
}
function isCruiseHighway(tags) {
const highway = tags.highway || "";
if (!highway || tags.area === "yes") return false;
@@ -36,70 +125,100 @@ function isCruiseHighway(tags) {
]).has(highway);
}
function directedRoadEdges(ways, nodes, bounds) {
function directedRoadEdges(network, ways, diagnostics) {
if (!Array.isArray(network.roads) || !network.gps_bounds) {
throw new Error("Invalid osm2streets network: expected roads and gps_bounds");
}
const waysById = new Map(ways.map((way) => [String(way.id), way]));
const edges = [];
for (const way of ways) {
if (!isCruiseHighway(way.tags)) continue;
const refs = compactRefs(way.refs);
if (refs.length < 2) continue;
const coords = refs.map((ref) => nodes.get(ref));
if (!routeInsideBounds(coords, bounds) || routeLength(coords) < 12) continue;
const oneway = String(way.tags.oneway || "").toLowerCase();
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
if (!isOneWay(oneway)) {
edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
for (const entry of network.roads) {
const road = Array.isArray(entry) ? entry[1] : null;
if (!road || !Number.isInteger(Number(road.id)) || !Array.isArray(road.lane_specs_ltr)) continue;
const wayIds = Array.isArray(road.osm_ids) ? road.osm_ids.map(String) : [];
const sourceWays = wayIds.map((id) => waysById.get(id)).filter(Boolean);
const sourceWay = sourceWays[0] || null;
const tags = sourceWay?.tags || { highway: road.highway_type || "" };
if (!isCruiseHighway(tags)) continue;
if (sourceWays.length > 1 && sourceWays.some((way) => JSON.stringify(way.tags) !== JSON.stringify(sourceWay.tags))) {
addDiagnostic(diagnostics, { reason: "ambiguous_internal_road_source", road: road.id, osmWayIds: wayIds });
continue;
}
const coordinates = networkPolylineToGps(road.center_line, network.gps_bounds);
if (coordinates.length < 2 || routeLength(coordinates) < 12) continue;
const directions = new Set(road.lane_specs_ltr
.filter((lane) => lane.lt === "Driving")
.map((lane) => lane.dir));
if (directions.has("Fwd")) edges.push(makeEdge(road, sourceWay, wayIds, coordinates, "forward"));
if (directions.has("Back")) edges.push(makeEdge(road, sourceWay, wayIds, [...coordinates].reverse(), "backward"));
}
return edges.sort((a, b) => a.id.localeCompare(b.id));
}
function makeEdge(way, refs, coordinates, direction) {
function makeEdge(road, way, wayIds, coordinates, direction) {
const forward = direction === "forward";
const tags = way?.tags || {};
return {
id: `${way.id}:${direction}`,
wayId: way.id,
id: `road-${road.id}:${direction}`,
roadId: Number(road.id),
wayId: wayIds[0] || "",
osmWayIds: wayIds,
direction,
name: way.tags.name || way.tags.highway || "road",
highway: way.tags.highway || "",
oneWay: way.tags.oneway || "",
startNode: refs[0],
endNode: refs[refs.length - 1],
name: road.name || tags.name || road.highway_type || "road",
highway: road.highway_type || tags.highway || "",
oneWay: directionsForRoad(road).size === 1 ? "yes" : "",
startNode: forward ? Number(road.src_i) : Number(road.dst_i),
endNode: forward ? Number(road.dst_i) : Number(road.src_i),
coordinates,
allowedTurns: allowedTurns(way.tags, direction),
allowedTurns: allowedTurns(tags, direction),
turnLanes: turnLanes(tags, direction),
};
}
function directionsForRoad(road) {
return new Set(road.lane_specs_ltr.filter((lane) => lane.lt === "Driving").map((lane) => lane.dir));
}
function networkPolylineToGps(polyline, bounds) {
const points = Array.isArray(polyline?.pts) ? polyline.pts : [];
const widthMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.max_lon, bounds.min_lat]);
const heightMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.min_lon, bounds.max_lat]);
if (!(widthMeters > 0) || !(heightMeters > 0)) return [];
return points.map((point) => {
const x = Number(point.x) / 10000;
const y = Number(point.y) / 10000;
return [
bounds.min_lon + x / widthMeters * (bounds.max_lon - bounds.min_lon),
bounds.min_lat + (bounds.max_lat - bounds.min_lat) * (heightMeters - y) / heightMeters,
];
}).filter((coordinate) => coordinate.every(Number.isFinite));
}
function isOneWay(value) {
return ["yes", "true", "1"].includes(value);
}
function compactRefs(refs) {
return refs.filter((ref, index) => index === 0 || ref !== refs[index - 1]);
}
function routeInsideBounds(coords, bounds) {
if (!bounds) return true;
return coords.some((coord) => insideBounds(coord, bounds));
}
function insideBounds(coord, bounds) {
const pad = 0.00002;
return coord[0] >= bounds.minLon - pad && coord[0] <= bounds.maxLon + pad &&
coord[1] >= bounds.minLat - pad && coord[1] <= bounds.maxLat + pad;
}
function allowedTurns(tags, direction) {
const value = tags[`turn:lanes:${direction}`] || tags["turn:lanes"];
if (!value) return ALL_TURNS;
const turns = new Set();
for (const lane of String(value).split("|")) {
for (const maneuver of lane.split(";")) {
const normalized = maneuver.trim().replace(/^slight_/, "");
if (ALL_TURNS.has(normalized)) turns.add(normalized);
}
}
const lanes = turnLanes(tags, direction);
if (!lanes) return ALL_TURNS;
const turns = new Set(lanes.flatMap((lane) => [...lane]).filter((turn) => ALL_TURNS.has(turn)));
return turns.size ? turns : ALL_TURNS;
}
function turnLanes(tags, direction) {
const value = tags[`turn:lanes:${direction}`] ?? tags["turn:lanes"];
if (value === undefined || value === "") return null;
return String(value).split("|").map((lane) => {
const turns = new Set(String(lane).split(";").map(normalizeTurn).filter(Boolean));
return turns.size ? turns : new Set(ALL_TURNS);
});
}
function normalizeTurn(value) {
const turn = String(value || "").trim().replace(/^slight_/, "");
if (turn === "reverse") return "u_turn";
return [...ALL_TURNS, "u_turn"].includes(turn) ? turn : null;
}
function findReturnRoutes(edges) {
const outgoing = new Map();
const byId = new Map();
@@ -110,14 +229,12 @@ function findReturnRoutes(edges) {
}
const candidates = [];
const seen = new Set();
for (const first of edges) {
walkToTerminal([first], [], outgoing, byId, candidates, seen);
}
for (const first of edges) walkToTerminal([first], [], outgoing, byId, candidates, seen);
return candidates.sort((a, b) => a.signature.localeCompare(b.signature));
}
function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
const current = path[path.length - 1];
const current = path.at(-1);
if (path.length >= MIN_ROUTE_EDGES) {
const route = returnRoute(path, maneuvers, byId);
if (route && !seen.has(route.signature)) {
@@ -139,7 +256,7 @@ function walkToTerminal(path, maneuvers, outgoing, byId, candidates, seen) {
}
function classifyConnection(incoming, outgoing) {
if (incoming.wayId === outgoing.wayId) return null;
if (incoming.roadId === outgoing.roadId) return null;
const inVector = directionVector(incoming.coordinates.at(-2), incoming.coordinates.at(-1));
const outVector = directionVector(outgoing.coordinates[0], outgoing.coordinates[1]);
const dot = inVector.x * outVector.x + inVector.y * outVector.y;
@@ -151,7 +268,7 @@ function classifyConnection(incoming, outgoing) {
}
function directionVector(a, b) {
const scale = 111320.0;
const scale = 111320;
const x = (b[0] - a[0]) * scale * Math.cos(degreesToRadians((a[1] + b[1]) / 2));
const y = (b[1] - a[1]) * scale;
const length = Math.hypot(x, y) || 1;
@@ -159,7 +276,7 @@ function directionVector(a, b) {
}
function returnRoute(path, forwardManeuvers, byId) {
const reverse = path.slice().reverse().map((edge) => byId.get(`${edge.wayId}:${oppositeDirection(edge.direction)}`));
const reverse = path.slice().reverse().map((edge) => byId.get(`road-${edge.roadId}:${oppositeDirection(edge.direction)}`));
if (reverse.some((edge) => !edge)) return null;
const returnManeuvers = [];
for (let index = 1; index < reverse.length; index += 1) {
@@ -167,67 +284,312 @@ function returnRoute(path, forwardManeuvers, byId) {
if (!maneuver) return null;
returnManeuvers.push(maneuver);
}
const signature = path.map((edge) => edge.wayId).sort().join(">");
return makeRoute(
[...path, ...reverse],
[...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
const signature = path.map((edge) => edge.roadId).join(">");
return {
edges: [...path, ...reverse],
maneuvers: [...forwardManeuvers, "u_turn", ...returnManeuvers, "u_turn"],
forwardEdgeCount: path.length,
signature,
);
};
}
function oppositeDirection(direction) {
return direction === "forward" ? "backward" : "forward";
}
function makeRoute(edges, maneuvers, signature) {
const coordinates = smoothRoute(edges);
function makeRoute(candidate, laneIndex, intersections, diagnostics) {
const selectedLanes = [];
for (let index = 0; index < candidate.edges.length; index += 1) {
const edge = candidate.edges[index];
const match = selectLaneForEdge(edge, candidate.maneuvers[index], laneIndex, {
// 仅去程中的真实路口受 turn:lanes 严格约束;端点调头与展示返程不能被反向标签否决。
enforceTurnRestrictions: index < candidate.forwardEdgeCount - 1,
});
if (!match.ok) {
addDiagnostic(diagnostics, {
reason: match.reason,
routeSignature: candidate.signature,
edgeId: edge.id,
road: edge.roadId,
osmWayId: edge.wayId,
direction: edge.direction,
maneuver: candidate.maneuvers[index],
detail: match.detail,
});
return null;
}
selectedLanes.push(match.lane);
}
const smoothed = smoothLaneRoute(candidate.edges, selectedLanes, intersections);
if (!smoothed.ok) {
addDiagnostic(diagnostics, { reason: smoothed.reason, routeSignature: candidate.signature, ...smoothed.detail });
return null;
}
const coordinates = smoothed.coordinates;
const centerlineCoordinates = smoothRoute(candidate.edges);
const route = {
id: `route-${signature.replace(/[^\w]+/g, "-")}`,
highway: edges[0].highway,
oneWay: edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
edgeIds: edges.map((edge) => edge.id),
maneuvers,
id: `route-${candidate.signature.replace(/[^\w]+/g, "-")}`,
highway: candidate.edges[0].highway,
oneWay: candidate.edges.some((edge) => isOneWay(String(edge.oneWay).toLowerCase())) ? "partial" : "",
edgeIds: candidate.edges.map((edge) => edge.id),
maneuvers: candidate.maneuvers,
lengthMeters: routeLength(coordinates),
laneOffsetMeters: LANE_OFFSET_METERS,
coordinates: offsetClosedRouteRight(coordinates, LANE_OFFSET_METERS),
centerlineCoordinates: coordinates,
coordinates,
centerlineCoordinates,
laneSegments: selectedLanes.flatMap((lane, edgeIndex) => lane.fragments.map((fragment) => ({
edgeId: candidate.edges[edgeIndex].id,
osmWayId: candidate.edges[edgeIndex].wayId,
direction: candidate.edges[edgeIndex].direction,
laneIndex: lane.laneIndex,
widthMeters: fragment.widthMeters,
centerOffsetMeters: Number(fragment.centerOffsetMeters.toFixed(3)),
maneuver: candidate.maneuvers[edgeIndex],
source: "lane_polygon_centerline",
polygonId: fragment.polygonId,
featureIndex: fragment.featureIndex,
road: fragment.road,
}))),
connectors: smoothed.connectors,
};
Object.defineProperty(route, "signature", { value: signature });
Object.defineProperty(route, "signature", { value: candidate.signature });
return route;
}
function selectLaneForEdge(edge, maneuver, laneIndex, options = {}) {
const enforceTurnRestrictions = options.enforceTurnRestrictions !== false;
const expectedDirection = edge.direction === "forward" ? "Fwd" : "Back";
const candidates = laneIndex.get(laneKey(edge.roadId, expectedDirection)) || [];
if (!candidates.length) return { ok: false, reason: "missing_lane_polygon" };
const lanes = [];
for (const fragment of candidates) {
const centerline = orientPolyline(fragment.centerline, edge.coordinates);
if (!centerline) return { ok: false, reason: "invalid_lane_polygon", detail: "direction_alignment" };
const midpoint = polylineMidpoint(centerline);
const offset = lateralOffsetFrom(edge.coordinates, midpoint);
if (!offset || offset.distance > MAX_LANE_DISTANCE_METERS) {
return { ok: false, reason: "missing_lane_polygon", detail: "geometry_too_far_from_internal_road" };
}
lanes.push({
laneIndex: fragment.laneIndex,
centerline,
centerOffsetMeters: offset.lateral,
allowedTurns: fragment.allowedTurns,
fragments: [{ ...fragment, centerline, centerOffsetMeters: offset.lateral }],
});
}
lanes.sort((a, b) => a.centerOffsetMeters - b.centerOffsetMeters || String(a.laneIndex).localeCompare(String(b.laneIndex)));
for (let index = 1; index < lanes.length; index += 1) {
if (lanes[index].centerOffsetMeters - lanes[index - 1].centerOffsetMeters < MIN_LATERAL_SEPARATION_METERS) {
return { ok: false, reason: "ambiguous_lane_order" };
}
}
if (enforceTurnRestrictions && edge.turnLanes && edge.turnLanes.length !== lanes.length) {
return { ok: false, reason: "ambiguous_lane_order", detail: "turn_lane_count_mismatch" };
}
let compatible = lanes.filter((lane, index) => laneSupportsManeuver(lane, edge.turnLanes?.[index], maneuver));
if (!compatible.length && !enforceTurnRestrictions) compatible = lanes;
if (!compatible.length) return { ok: false, reason: "no_compatible_turn_lane" };
const chooseLeft = maneuver === "left" || maneuver === "u_turn";
return { ok: true, lane: chooseLeft ? compatible[0] : compatible.at(-1) };
}
function laneSupportsManeuver(lane, osmTurns, maneuver) {
const expected = maneuver === "u_turn" ? "left" : maneuver;
if (osmTurns && !osmTurns.has(expected) && !(maneuver === "u_turn" && osmTurns.has("u_turn"))) return false;
if (lane.allowedTurns.size && !lane.allowedTurns.has(expected) && !(maneuver === "u_turn" && lane.allowedTurns.has("u_turn"))) return false;
return true;
}
function laneKey(roadId, direction) {
return `${String(roadId)}:${direction}`;
}
function addDiagnostic(diagnostics, entry) {
const key = JSON.stringify(entry);
if (!diagnostics.some((current) => JSON.stringify(current) === key)) diagnostics.push(entry);
}
function indexIntersections(network, features) {
if (!Array.isArray(network.intersections)) throw new Error("Invalid osm2streets network: expected intersections");
const surfaces = new Map(features
.filter((feature) => feature?.geometry?.type === "Polygon" && Number.isInteger(Number(feature.properties?.id)))
.map((feature) => [Number(feature.properties.id), feature.geometry.coordinates[0]]));
const intersections = new Map();
for (const entry of network.intersections) {
const intersection = Array.isArray(entry) ? entry[1] : null;
if (!intersection || !Number.isInteger(Number(intersection.id))) continue;
intersections.set(Number(intersection.id), {
id: Number(intersection.id),
osmNodeIds: Array.isArray(intersection.osm_ids) ? intersection.osm_ids.map(String) : [],
surface: surfaces.get(Number(intersection.id)) || null,
});
}
return intersections;
}
function smoothLaneRoute(edges, selectedLanes, intersections) {
const route = [];
const connectors = [];
for (let index = 0; index < edges.length; index += 1) {
const current = selectedLanes[index].centerline;
appendCoordinates(route, current);
const nextIndex = (index + 1) % edges.length;
const next = selectedLanes[nextIndex].centerline;
const incomingEdge = edges[index];
const outgoingEdge = edges[nextIndex];
if (incomingEdge.endNode !== outgoingEdge.startNode) {
return { ok: false, reason: "disconnected_internal_roads", detail: { fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId } };
}
const intersection = intersections.get(incomingEdge.endNode);
if (!intersection?.surface) {
return { ok: false, reason: "missing_intersection_surface", detail: { intersectionId: incomingEdge.endNode } };
}
const isUTurn = incomingEdge.roadId === outgoingEdge.roadId;
const turn = constrainedConnector(current, next, incomingEdge.coordinates.at(-1), intersection.surface, isUTurn);
if (!turn) {
return {
ok: false,
reason: "connector_outside_intersection",
detail: { intersectionId: intersection.id, fromRoad: incomingEdge.roadId, toRoad: outgoingEdge.roadId },
};
}
appendCoordinates(route, turn.slice(1));
connectors.push({
intersectionId: intersection.id,
osmNodeIds: intersection.osmNodeIds,
fromRoad: incomingEdge.roadId,
toRoad: outgoingEdge.roadId,
maneuver: isUTurn ? "u_turn" : classifyConnection(incomingEdge, outgoingEdge),
source: "intersection_surface_constrained",
coordinates: turn,
});
}
if (route.length) route[route.length - 1] = [...route[0]];
return { ok: true, coordinates: route, connectors };
}
function constrainedConnector(incoming, outgoing, junction, surface, isUTurn) {
const scales = isUTurn ? [1, 0.8, 0.6, 0.4, 0.25] : [1, 0.75, 0.5, 0.3, 0.15];
for (const scale of scales) {
const connector = isUTurn
? uTurnConnector(incoming, outgoing, junction, 20, scale)
: tangentBezierTurn(incoming, outgoing, 16, scale);
if (connector.length && connector.every((point) => pointInPolygonOrNear(point, surface, CONNECTOR_SURFACE_TOLERANCE_METERS))) {
return connector;
}
}
return null;
}
function pointInPolygonOrNear(point, ring, toleranceMeters) {
if (!Array.isArray(ring) || ring.length < 4) return false;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const a = ring[i];
const b = ring[j];
if ((a[1] > point[1]) !== (b[1] > point[1]) &&
point[0] < (b[0] - a[0]) * (point[1] - a[1]) / (b[1] - a[1]) + a[0]) inside = !inside;
if (distanceToSegmentMeters(point, a, b) <= toleranceMeters) return true;
}
return inside;
}
function distanceToSegmentMeters(point, start, end) {
const latitude = (point[1] + start[1] + end[1]) / 3;
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
const dx = (end[0] - start[0]) * metersLon;
const dy = (end[1] - start[1]) * 111320;
const px = (point[0] - start[0]) * metersLon;
const py = (point[1] - start[1]) * 111320;
const lengthSquared = dx * dx + dy * dy;
const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0;
return Math.hypot(px - dx * ratio, py - dy * ratio);
}
function smoothRoute(edges) {
const trimmed = edges.map((edge) => trimPolyline(edge.coordinates, JUNCTION_TRIM_METERS));
const route = [];
for (let index = 0; index < edges.length; index += 1) {
appendCoordinates(route, trimmed[index]);
const nextIndex = (index + 1) % edges.length;
const junction = edges[index].coordinates.at(-1);
const turn = edges[index].wayId === edges[nextIndex].wayId
? uTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0])
: bezierTurn(trimmed[index].at(-1), junction, trimmed[nextIndex][0], 6);
const turn = edges[index].roadId === edges[nextIndex].roadId
? uTurnConnector(trimmed[index], trimmed[nextIndex], edges[index].coordinates.at(-1))
: tangentBezierTurn(trimmed[index], trimmed[nextIndex]);
appendCoordinates(route, turn.slice(1));
}
if (route.length) route[route.length - 1] = [...route[0]];
return route;
}
function uTurn(start, junction, end) {
const tangent = directionVector(start, junction);
const left = offsetCoordinate(junction, -tangent.y * 3.0, tangent.x * 3.0);
const right = offsetCoordinate(junction, tangent.y * 3.0, -tangent.x * 3.0);
return [
start,
lerpCoordinate(start, junction, 0.72),
left,
right,
lerpCoordinate(end, junction, 0.72),
end,
];
function tangentBezierTurn(incoming, outgoing, samples = 16, scale = 1) {
if (incoming.length < 2 || outgoing.length < 2) return [];
const start = incoming.at(-1);
const end = outgoing[0];
const incomingTangent = directionVector(incoming.at(-2), start);
const outgoingTangent = directionVector(end, outgoing[1]);
const incomingSpan = haversineMeters(incoming.at(-2), start);
const outgoingSpan = haversineMeters(end, outgoing[1]);
const intersection = intersectTangentRays(start, end, incomingTangent, outgoingTangent);
let controlA;
let controlB;
if (intersection && intersection.a >= 0 && intersection.b >= 0) {
// 两条车道切线的前向交点定义了转弯的几何目标Bezier 控制点取三分之一距离。
const maxA = Math.min(8, Math.max(0.75, incomingSpan * 2.4));
const maxB = Math.min(8, Math.max(0.75, outgoingSpan * 2.4));
const distanceA = Math.min(intersection.a, maxA) * scale;
const distanceB = Math.min(intersection.b, maxB) * scale;
controlA = offsetCoordinate(start, incomingTangent.x * distanceA / 3, incomingTangent.y * distanceA / 3);
controlB = offsetCoordinate(end, -outgoingTangent.x * distanceB / 3, -outgoingTangent.y * distanceB / 3);
} else {
// 平行、反向或交点在车道后方时,使用受限 fallback避免生成反向回环。
const chordMeters = haversineMeters(start, end);
const controlMeters = boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, 0.42, 8) * scale;
controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
}
return cubicBezier(start, controlA, controlB, end, samples);
}
function intersectTangentRays(start, end, incomingTangent, outgoingTangent) {
const latitude = (start[1] + end[1]) / 2;
const metersLon = 111320 * Math.cos(degreesToRadians(latitude));
const qx = (end[0] - start[0]) * metersLon;
const qy = (end[1] - start[1]) * 111320;
const cross = incomingTangent.x * outgoingTangent.y - incomingTangent.y * outgoingTangent.x;
if (Math.abs(cross) < 1e-6) return null;
const crossQOutgoing = qx * outgoingTangent.y - qy * outgoingTangent.x;
const crossQIncoming = qx * incomingTangent.y - qy * incomingTangent.x;
return {
a: crossQOutgoing / cross,
b: crossQIncoming / cross,
};
}
function uTurnConnector(incoming, outgoing, junction, samples = 20, scale = 1) {
if (incoming.length < 2 || outgoing.length < 2) return [];
const start = incoming.at(-1);
const end = outgoing[0];
const incomingTangent = directionVector(incoming.at(-2), start);
const outgoingTangent = directionVector(end, outgoing[1]);
const chordMeters = haversineMeters(start, end);
const approachMeters = Math.max(haversineMeters(start, junction), haversineMeters(end, junction));
const incomingSpan = haversineMeters(incoming.at(-2), start);
const outgoingSpan = haversineMeters(end, outgoing[1]);
const availableMeters = Math.max(0.5, Math.min(10, incomingSpan * 0.8, outgoingSpan * 0.8));
const controlMeters = Math.min(availableMeters, Math.max(Math.min(2, availableMeters), chordMeters * 1.1, approachMeters * 0.6)) * scale;
const controlA = offsetCoordinate(start, incomingTangent.x * controlMeters, incomingTangent.y * controlMeters);
const controlB = offsetCoordinate(end, -outgoingTangent.x * controlMeters, -outgoingTangent.y * controlMeters);
return cubicBezier(start, controlA, controlB, end, samples);
}
function boundedControlDistance(chordMeters, incomingSpan, outgoingSpan, ratio, maximumMeters) {
const lowerMeters = Math.min(1.5, chordMeters * 0.35);
const upperMeters = Math.max(0.25, Math.min(maximumMeters, chordMeters * 0.65, incomingSpan * 0.8, outgoingSpan * 0.8));
return Math.min(upperMeters, Math.max(lowerMeters, chordMeters * ratio));
}
function offsetCoordinate(coord, eastMeters, northMeters) {
const metersPerLat = 111320.0;
const metersPerLat = 111320;
const metersPerLon = metersPerLat * Math.cos(degreesToRadians(coord[1]));
return [coord[0] + eastMeters / metersPerLon, coord[1] + northMeters / metersPerLat];
}
@@ -249,9 +611,7 @@ function pointAlong(coords, distance) {
return [...coords.at(-1)];
}
function bezierTurn(start, junction, end, samples) {
const controlA = lerpCoordinate(start, junction, 0.72);
const controlB = lerpCoordinate(end, junction, 0.72);
function cubicBezier(start, controlA, controlB, end, samples) {
const points = [];
for (let index = 0; index <= samples; index += 1) {
const t = index / samples;
@@ -264,19 +624,6 @@ function bezierTurn(start, junction, end, samples) {
return points;
}
function appendCoordinates(target, coordinates) {
for (const coord of coordinates) {
const last = target.at(-1);
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) target.push([...coord]);
}
}
function offsetClosedRouteRight(coords, offset) {
const shifted = offsetPolylineRight(coords, offset);
if (shifted.length) shifted[shifted.length - 1] = [...shifted[0]];
return shifted;
}
function selectRoutes(candidates) {
const selected = [];
const covered = new Set();
@@ -295,43 +642,25 @@ function routeScore(route, covered) {
return novelty * 100000 + route.lengthMeters;
}
function offsetPolylineRight(coords, offsetMeters) {
if (coords.length < 2 || offsetMeters === 0) return coords.map((coord) => [...coord]);
const refLat = coords.reduce((sum, coord) => sum + coord[1], 0) / coords.length;
const metersPerLat = 111320.0;
const metersPerLon = 111320.0 * Math.cos(degreesToRadians(refLat));
const points = coords.map((coord) => ({ x: coord[0] * metersPerLon, y: coord[1] * metersPerLat, lon: coord[0], lat: coord[1] }));
return points.map((point, index) => {
const prev = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
const length = Math.hypot(next.x - prev.x, next.y - prev.y);
if (length < 0.001) return [point.lon, point.lat];
const dx = (next.x - prev.x) / length;
const dy = (next.y - prev.y) / length;
return [(point.x + dy * offsetMeters) / metersPerLon, (point.y - dx * offsetMeters) / metersPerLat];
});
}
function routeLength(coords) {
let total = 0;
for (let index = 1; index < coords.length; index += 1) total += haversineMeters(coords[index - 1], coords[index]);
return total;
}
function haversineMeters(a, b) {
const radius = 6371008.8;
const lat1 = degreesToRadians(a[1]);
const lat2 = degreesToRadians(b[1]);
const dLat = degreesToRadians(b[1] - a[1]);
const dLon = degreesToRadians(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * radius * Math.asin(Math.min(1, Math.sqrt(h)));
return polylineLength(coords);
}
function lerpCoordinate(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
function degreesToRadians(value) { return value * Math.PI / 180; }
function degreesToRadians(value) {
return value * Math.PI / 180;
}
module.exports = { buildVehicleRoute, classifyConnection, allowedTurns };
module.exports = {
allowedTurns,
buildVehicleRoute,
classifyConnection,
readLanePolygons,
selectLaneForEdge,
tangentBezierTurn,
turnLanes,
uTurnConnector,
};

View File

@@ -6,8 +6,14 @@ const fs = require("fs");
const os = require("os");
const path = require("path");
const { normalizeAreaConfig } = require("./lib/area-config");
const { stageManifestStatus } = require("./lib/area-diagnostics");
const { digestGltf } = require("./glb-digest");
const { evaluateGlbBudget, BUDGETS } = require("./lib/stage-manifest");
const { evaluateGlbBudget, BUDGETS, fileRecord, writeStageManifest } = require("./lib/stage-manifest");
const qgisBuildSource = fs.readFileSync(path.join(__dirname, "build-osm2streets-qgis.js"), "utf8");
assert.match(qgisBuildSource, /QgsFieldConstraints\.Constraint\.ConstraintNotNull/);
assert.match(qgisBuildSource, /QgsFieldConstraints\.ConstraintNotNull/);
assert.doesNotMatch(qgisBuildSource, /setFieldConstraint\(index, 1\)/);
const gltf = {
nodes: [
@@ -61,6 +67,63 @@ assert.equal(
normalizeAreaConfig({ ...base, budget: { nodes: 1200, reason: "Dense campus vegetation" } }).budget.glbNodes,
1200,
);
const configPath = path.join(tempDir, "area.json");
fs.writeFileSync(configPath, `${JSON.stringify(base)}\n`);
const area = normalizeAreaConfig(base);
fs.mkdirSync(area.outputs.geojsonDir, { recursive: true });
for (const file of [area.outputs.glb, area.outputs.metadata, area.outputs.cesiumPreview, area.outputs.vehicleRoute, area.outputs.vehicleModel]) {
fs.writeFileSync(file, "fixture\n");
}
const lanePolygons = path.join(area.outputs.geojsonDir, "lane_polygons.geojson");
const emptyFeatureCollection = '{"type":"FeatureCollection","features":[]}\n';
const emptyNetwork = '{"roads":[],"intersections":[],"gps_bounds":{}}\n';
fs.writeFileSync(lanePolygons, emptyFeatureCollection);
const network = path.join(area.outputs.geojsonDir, "network.json");
fs.writeFileSync(network, emptyNetwork);
const intersectionSurface = path.join(area.outputs.geojsonDir, "intersection_surface.geojson");
fs.writeFileSync(intersectionSurface, emptyFeatureCollection);
const previewCss = path.join(__dirname, "lib", "cesium-preview.css");
const previewJs = path.join(__dirname, "lib", "cesium-preview.js");
writeStageManifest(area, {
stage: "preview",
status: "ok",
config: configPath,
inputs: {
config: fileRecord(configPath),
osm: fileRecord(input),
glb: fileRecord(area.outputs.glb),
metadata: fileRecord(area.outputs.metadata),
lanePolygons: fileRecord(lanePolygons),
network: fileRecord(network),
intersectionSurface: fileRecord(intersectionSurface),
previewCss: fileRecord(previewCss),
previewJs: fileRecord(previewJs),
},
outputs: {
cesiumPreview: fileRecord(area.outputs.cesiumPreview),
vehicleRoute: fileRecord(area.outputs.vehicleRoute),
vehicleModel: fileRecord(area.outputs.vehicleModel),
},
summary: {},
warnings: [],
});
let previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, true);
fs.appendFileSync(lanePolygons, " \n");
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, false);
assert.ok(previewManifest.issues.some((issue) => issue.includes("lanePolygons")));
fs.writeFileSync(lanePolygons, emptyFeatureCollection);
fs.appendFileSync(network, " \n");
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, false);
assert.ok(previewManifest.issues.some((issue) => issue.includes("network")));
fs.writeFileSync(network, emptyNetwork);
fs.appendFileSync(intersectionSurface, " \n");
previewManifest = stageManifestStatus(area, configPath).find((manifest) => manifest.stage === "preview");
assert.equal(previewManifest.fresh, false);
assert.ok(previewManifest.issues.some((issue) => issue.includes("intersectionSurface")));
fs.rmSync(tempDir, { recursive: true, force: true });
console.log("Asset budget tests passed.");

View File

@@ -8,12 +8,22 @@ const path = require("path");
const { cesiumPreviewHtml } = require("./lib/area-preview");
const { makeVehicleGltf } = require("./lib/vehicle-model");
const { VEHICLE_IDS, REVERSED_MODEL_IDS, writePreviewVehicleLibrary } = require("./lib/vehicle-library");
const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route");
const {
allowedTurns,
buildVehicleRoute,
classifyConnection,
tangentBezierTurn,
uTurnConnector,
} = require("./lib/vehicle-route");
const { buildTrafficSignals } = require("./lib/traffic-signals");
const { haversineMeters, laneCenterline } = require("./lib/lane-geometry");
const { parseOsm } = require("./lib/osm");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
const osmPath = path.join(tempDir, "fixture.osm");
const lanePolygonsPath = path.join(tempDir, "lane_polygons.geojson");
const networkPath = path.join(tempDir, "network.json");
const intersectionSurfacePath = path.join(tempDir, "intersection_surface.geojson");
fs.writeFileSync(osmPath, `<?xml version="1.0"?>
<osm version="0.6">
@@ -25,11 +35,11 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
<node id="5" lon="120.009" lat="30.002"/>
<way id="west-road">
<nd ref="1"/><nd ref="2"/>
<tag k="highway" v="primary"/><tag k="turn:lanes:forward" v="left|through"/>
<tag k="highway" v="primary"/><tag k="lanes" v="4"/><tag k="lanes:forward" v="2"/><tag k="lanes:backward" v="2"/><tag k="turn:lanes:forward" v="left|through"/>
</way>
<way id="turn-road">
<nd ref="2"/><nd ref="3"/>
<tag k="highway" v="residential"/><tag k="turn:lanes:forward" v="through|left"/>
<tag k="highway" v="residential"/><tag k="lanes" v="3"/><tag k="lanes:forward" v="2"/><tag k="lanes:backward" v="1"/><tag k="turn:lanes:forward" v="through|left"/><tag k="turn:lanes:backward" v="right"/>
</way>
<way id="east-road">
<nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/>
@@ -43,8 +53,44 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
</osm>
`);
const route = buildVehicleRoute(osmPath);
const roadCoordinates = {
"west-road": [[120.001, 30.001], [120.003, 30.001]],
"turn-road": [[120.003, 30.001], [120.005, 30.002]],
"east-road": [[120.005, 30.002], [120.007, 30.002]],
};
const laneFeatures = [
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Back", 3.5, [5.25, 1.75], 0),
...directionalLanes("west-road", 0, roadCoordinates["west-road"], "Fwd", 3.5, [1.75, 5.25], 2),
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Back", 3.0, [1.5], 0),
...directionalLanes("turn-road", 1, roadCoordinates["turn-road"], "Fwd", 3.0, [1.5, 4.5], 1),
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Back", 3.5, [1.75], 0),
...directionalLanes("east-road", 2, roadCoordinates["east-road"], "Fwd", 3.5, [1.75], 1),
{ type: "Feature", properties: { type: "Driving", direction: "Fwd", index: 99, width: 3, road: 99, osm_way_ids: ["broken"] }, geometry: { type: "Polygon", coordinates: [[]] } },
];
fs.writeFileSync(lanePolygonsPath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures })}\n`);
const fixtureBounds = { min_lon: 120, min_lat: 30, max_lon: 120.01, max_lat: 30.01 };
const networkRoads = [
networkRoad(0, "west-road", 0, 1, roadCoordinates["west-road"], [laneSpec("Back", 3.5), laneSpec("Back", 3.5), laneSpec("Fwd", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds, "primary"),
networkRoad(1, "turn-road", 1, 2, roadCoordinates["turn-road"], [laneSpec("Back", 3), laneSpec("Fwd", 3), laneSpec("Fwd", 3)], fixtureBounds),
networkRoad(2, "east-road", 2, 3, roadCoordinates["east-road"], [laneSpec("Back", 3.5), laneSpec("Fwd", 3.5)], fixtureBounds),
networkRoad(3, "oneway-spur", 3, 4, [[120.007, 30.002], [120.009, 30.002]], [laneSpec("Fwd", 3.5)], fixtureBounds),
];
fs.writeFileSync(networkPath, `${JSON.stringify({
roads: networkRoads.map((road) => [road.id, road]),
intersections: [0, 1, 2, 3, 4].map((id) => [id, { id, osm_ids: [String(id + 1)] }]),
gps_bounds: fixtureBounds,
})}\n`);
fs.writeFileSync(intersectionSurfacePath, `${JSON.stringify({
type: "FeatureCollection",
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
.map((coordinate, id) => intersectionFeature(id, coordinate, 9)),
})}\n`);
const route = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, intersectionSurfacePath);
assert.equal(route.source, osmPath);
assert.equal(route.laneSource, lanePolygonsPath);
assert.equal(route.networkSource, networkPath);
assert.equal(route.intersectionSource, intersectionSurfacePath);
assert.deepEqual(route.bounds, {
minLon: 120,
minLat: 30,
@@ -59,16 +105,97 @@ assert.ok(route.routes.every((segment) => segment.edgeIds.length >= 6));
assert.ok(route.routes.every((segment) => segment.maneuvers.includes("u_turn")));
assert.ok(route.routes.every((segment) => segment.maneuvers.some((value) => ["left", "right", "through"].includes(value))));
assert.ok(route.routes.every((segment) => JSON.stringify(segment.coordinates[0]) === JSON.stringify(segment.coordinates.at(-1))));
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("oneway-spur:backward")));
assert.ok(route.routes.every((segment) => !segment.edgeIds.includes("road-3:backward")));
assert.notDeepEqual(route.routes[0].coordinates, route.routes[0].centerlineCoordinates);
assert.ok(route.routes.every((segment) => !Object.hasOwn(segment, "laneOffsetMeters")));
assert.ok(route.routes.every((segment) => segment.laneSegments.length >= segment.edgeIds.length));
assert.ok(route.routes.every((segment) => segment.connectors.length === segment.edgeIds.length));
assert.ok(route.routes.flatMap((segment) => segment.connectors).every((connector) =>
connector.source === "intersection_surface_constrained" && connector.coordinates.length >= 2
));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).some((segment) => segment.widthMeters === 3.5));
assert.ok(route.routes.flatMap((segment) => segment.laneSegments).every((segment) =>
segment.source === "lane_polygon_centerline" && Number.isFinite(segment.centerOffsetMeters)
));
for (const segment of route.routes) {
for (const lane of segment.laneSegments) {
const polygonCenterline = laneCenterline(laneFeatures[lane.featureIndex]);
assert.ok(polygonCenterline, "selected lane polygon has a valid centerline");
assert.ok(polygonCenterline.every((point) =>
Math.min(...segment.coordinates.map((coordinate) => haversineMeters(point, coordinate))) <= 0.10
), "route coordinates retain every selected lane centerline point within 0.10 m");
}
}
assert.ok(route.diagnostics.some((entry) => entry.reason === "invalid_lane_polygon"));
const westThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
lane.osmWayId === "west-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 3
);
assert.ok(westThrough, "through uses the rightmost compatible lane on west-road");
assert.ok(Math.abs(westThrough.centerOffsetMeters - 5.25) <= 0.01, "3.5 m lane geometry produces the 5.25 m outer-lane center");
const turnThrough = route.routes.flatMap((segment) => segment.laneSegments).find((lane) =>
lane.osmWayId === "turn-road" && lane.direction === "forward" && lane.maneuver === "through" && lane.laneIndex === 1
);
assert.ok(turnThrough, "turn lane restrictions override the default rightmost choice");
assert.ok(Math.abs(turnThrough.centerOffsetMeters - 1.5) <= 0.01, "3.0 m lane geometry produces the 1.5 m inner-lane center");
assert.ok(route.routes.some((segment) => segment.laneSegments.some((lane) =>
lane.osmWayId === "turn-road" && lane.direction === "backward" && lane.maneuver === "through"
)), "display return survives an incompatible reverse turn:lanes tag");
const missingLanePath = path.join(tempDir, "missing-lane.geojson");
fs.writeFileSync(missingLanePath, `${JSON.stringify({ type: "FeatureCollection", features: laneFeatures.filter((feature) =>
!feature.properties.osm_way_ids.includes("east-road")
) })}\n`);
const missingLaneRoute = buildVehicleRoute(osmPath, missingLanePath, networkPath, intersectionSurfacePath);
assert.equal(missingLaneRoute.routes.length, 0);
assert.ok(missingLaneRoute.diagnostics.some((entry) => entry.reason === "missing_lane_polygon"));
const tinyIntersectionSurfacePath = path.join(tempDir, "tiny-intersection-surface.geojson");
fs.writeFileSync(tinyIntersectionSurfacePath, `${JSON.stringify({
type: "FeatureCollection",
features: [[120.001, 30.001], [120.003, 30.001], [120.005, 30.002], [120.007, 30.002], [120.009, 30.002]]
.map((coordinate, id) => intersectionFeature(id, coordinate, 0.1)),
})}\n`);
const rejectedConnectors = buildVehicleRoute(osmPath, lanePolygonsPath, networkPath, tinyIntersectionSurfacePath);
assert.equal(rejectedConnectors.routes.length, 0);
assert.ok(rejectedConnectors.diagnostics.some((entry) => entry.reason === "connector_outside_intersection"));
assert.throws(() => buildVehicleRoute(osmPath, path.join(tempDir, "absent.geojson"), networkPath, intersectionSurfacePath), /Invalid lane polygons JSON/);
const quad = laneFeatures[0];
assert.equal(laneCenterline(quad).length, 2);
assert.equal(laneCenterline({ geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [0, 0]]] } }), null);
assert.deepEqual(
[...allowedTurns({ "turn:lanes:forward": "left|through;right" }, "forward")].sort(),
["left", "right", "through"],
);
const incoming = { wayId: "in", coordinates: [[120, 30], [120.001, 30]] };
assert.equal(classifyConnection(incoming, { wayId: "left", coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
assert.equal(classifyConnection(incoming, { wayId: "right", coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
assert.equal(classifyConnection(incoming, { wayId: "through", coordinates: [[120.001, 30], [120.002, 30]] }), "through");
const incoming = { roadId: 1, coordinates: [[120, 30], [120.001, 30]] };
assert.equal(classifyConnection(incoming, { roadId: 2, coordinates: [[120.001, 30], [120.001, 30.001]] }), "left");
assert.equal(classifyConnection(incoming, { roadId: 3, coordinates: [[120.001, 30], [120.001, 29.999]] }), "right");
assert.equal(classifyConnection(incoming, { roadId: 4, coordinates: [[120.001, 30], [120.002, 30]] }), "through");
const connectorOrigin = [120, 30];
const incomingLane = [metersCoordinate(connectorOrigin, -12, -1.5), metersCoordinate(connectorOrigin, -5, -1.5)];
const leftOutgoingLane = [metersCoordinate(connectorOrigin, 1.5, 5), metersCoordinate(connectorOrigin, 1.5, 12)];
const rightOutgoingLane = [metersCoordinate(connectorOrigin, -1.5, -5), metersCoordinate(connectorOrigin, -1.5, -12)];
for (const [label, outgoingLane] of [["left", leftOutgoingLane], ["right", rightOutgoingLane]]) {
const connector = tangentBezierTurn(incomingLane, outgoingLane);
assert.deepEqual(connector[0], incomingLane.at(-1), `${label} connector retains the incoming lane endpoint`);
assert.deepEqual(connector.at(-1), outgoingLane[0], `${label} connector retains the outgoing lane endpoint`);
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), connector[0], connector[1]) < 5,
`${label} connector enters along the incoming lane tangent`);
assert.ok(tangentMismatchDegrees(connector.at(-2), connector.at(-1), outgoingLane[0], outgoingLane[1]) < 5,
`${label} connector exits along the outgoing lane tangent`);
assert.ok(maxStepMeters(connector) < 1.5, `${label} connector sampling has no abnormal position jump`);
}
const uTurnOutgoingLane = [metersCoordinate(connectorOrigin, -5, 1.5), metersCoordinate(connectorOrigin, -12, 1.5)];
const uTurn = uTurnConnector(incomingLane, uTurnOutgoingLane, connectorOrigin);
assert.deepEqual(uTurn[0], incomingLane.at(-1));
assert.deepEqual(uTurn.at(-1), uTurnOutgoingLane[0]);
assert.ok(tangentMismatchDegrees(incomingLane.at(-2), incomingLane.at(-1), uTurn[0], uTurn[1]) < 5,
"U-turn enters along the incoming lane tangent");
assert.ok(tangentMismatchDegrees(uTurn.at(-2), uTurn.at(-1), uTurnOutgoingLane[0], uTurnOutgoingLane[1]) < 5,
"U-turn exits along the outgoing lane tangent");
assert.ok(maxStepMeters(uTurn) < 1, "U-turn sampling has no abnormal position jump");
assert.ok(Math.max(...uTurn.map((coordinate) => eastMeters(connectorOrigin, coordinate))) > -3,
"U-turn forms a forward loop instead of a fixed lateral polyline");
const vehicle = makeVehicleGltf();
assert.equal(vehicle.asset.version, "2.0");
@@ -139,6 +266,8 @@ assert.match(previewRuntime, /TrafficSignalDynamic_/);
assert.match(previewRuntime, /TrafficSignalDynamic_\$\{nodeKey\}_countdown_\$\{String\(value\)\.padStart\(2, "0"\)\}/);
assert.match(previewRuntime, /ColorBlendMode\.REPLACE/);
assert.match(previewRuntime, /setBuildingGhost/);
assert.match(previewRuntime, /fetch\(url, \{ cache: "no-store" \}\)/);
assert.match(previewRuntime, /syncSelectedRouteVisibility\(cruise\)/);
assert.match(previewRuntime, /buildings\.model\.color = Cesium\.Color\.WHITE\.withAlpha\(0\.22\)/);
assert.match(previewRuntime, /asset\.category === "countdown"/);
assert.doesNotMatch(previewRuntime, /createCountdownDigits/);
@@ -205,3 +334,100 @@ function rectangle(lon, lat, halfWidth, halfHeight) {
[lon + halfWidth, lat + halfHeight], [lon - halfWidth, lat + halfHeight], [lon - halfWidth, lat - halfHeight],
]] } };
}
function directionalLanes(osmWayId, roadId, coordinates, direction, widthMeters, offsets, firstIndex) {
const oriented = direction === "Fwd" ? coordinates : [...coordinates].reverse();
return offsets.map((offsetMeters, index) => lanePolygon(
osmWayId, roadId, oriented, direction, firstIndex + index, widthMeters, offsetMeters,
));
}
function lanePolygon(osmWayId, roadId, coordinates, direction, index, widthMeters, offsetMeters) {
const centerline = offsetLineRight(coordinates, offsetMeters);
const left = offsetLineRight(centerline, -widthMeters / 2);
const right = offsetLineRight(centerline, widthMeters / 2);
return {
type: "Feature",
properties: {
type: "Driving",
direction,
index,
width: widthMeters,
road: roadId,
osm_way_ids: [osmWayId],
allowed_turns: [],
},
geometry: { type: "Polygon", coordinates: [[...left, ...right.reverse(), left[0]]] },
};
}
function laneSpec(direction, widthMeters) {
return { lt: "Driving", dir: direction, width: widthMeters * 10000, allowed_turns: 0 };
}
function networkRoad(id, osmWayId, src, dst, coordinates, laneSpecs, bounds, highwayType = "residential") {
return {
id,
osm_ids: [osmWayId],
src_i: src,
dst_i: dst,
highway_type: highwayType,
name: osmWayId,
center_line: { pts: coordinates.map((coordinate) => networkPoint(coordinate, bounds)) },
lane_specs_ltr: laneSpecs,
};
}
function networkPoint([lon, lat], bounds) {
const widthMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.max_lon, bounds.min_lat]);
const heightMeters = haversineMeters([bounds.min_lon, bounds.min_lat], [bounds.min_lon, bounds.max_lat]);
return {
x: Math.round((lon - bounds.min_lon) / (bounds.max_lon - bounds.min_lon) * widthMeters * 10000),
y: Math.round((heightMeters - (lat - bounds.min_lat) / (bounds.max_lat - bounds.min_lat) * heightMeters) * 10000),
};
}
function intersectionFeature(id, coordinate, halfSizeMeters) {
const west = metersCoordinate(coordinate, -halfSizeMeters, 0)[0];
const east = metersCoordinate(coordinate, halfSizeMeters, 0)[0];
const south = metersCoordinate(coordinate, 0, -halfSizeMeters)[1];
const north = metersCoordinate(coordinate, 0, halfSizeMeters)[1];
return {
type: "Feature",
properties: { id, type: "intersection" },
geometry: { type: "Polygon", coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] },
};
}
function offsetLineRight(coordinates, offsetMeters) {
const [start, end] = coordinates;
const latitude = (start[1] + end[1]) / 2;
const metersLon = 111320 * Math.cos(latitude * Math.PI / 180);
const dx = (end[0] - start[0]) * metersLon;
const dy = (end[1] - start[1]) * 111320;
const length = Math.hypot(dx, dy);
const east = dy / length * offsetMeters;
const north = -dx / length * offsetMeters;
return coordinates.map(([lon, lat]) => [lon + east / metersLon, lat + north / 111320]);
}
function metersCoordinate(origin, east, north) {
const metersLon = 111320 * Math.cos(origin[1] * Math.PI / 180);
return [origin[0] + east / metersLon, origin[1] + north / 111320];
}
function eastMeters(origin, coordinate) {
return (coordinate[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180);
}
function tangentMismatchDegrees(a, b, c, d) {
const metersLon = 111320 * Math.cos((b[1] + c[1]) / 2 * Math.PI / 180);
const first = [(b[0] - a[0]) * metersLon, (b[1] - a[1]) * 111320];
const second = [(d[0] - c[0]) * metersLon, (d[1] - c[1]) * 111320];
const cosine = (first[0] * second[0] + first[1] * second[1]) / (Math.hypot(...first) * Math.hypot(...second));
return Math.acos(Math.max(-1, Math.min(1, cosine))) * 180 / Math.PI;
}
function maxStepMeters(coordinates) {
return Math.max(...coordinates.slice(1).map((coordinate, index) => haversineMeters(coordinates[index], coordinate)));
}