From 5658e7337d4d0b3e1a60f0382fb93d21327c3860 Mon Sep 17 00:00:00 2001 From: que01 Date: Sat, 8 Aug 2026 15:21:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20Cesium=20=E5=B7=A1?= =?UTF-8?q?=E8=88=AA=E8=BD=A6=E9=81=93=E4=B8=AD=E5=BF=83=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/blender/asset-generation.md | 23 + .../spec/guides/cross-layer-thinking-guide.md | 14 + .trellis/spec/pipeline/cli-and-stages.md | 3 + .trellis/spec/preview/index.md | 3 + .trellis/spec/preview/vehicle-routes.md | 18 +- README.md | 6 +- blender/osmassets/osm.py | 44 +- blender/tests/test_pure.py | 21 +- scripts/build-area.js | 16 +- scripts/build-osm2streets-qgis.js | 8 +- scripts/lib/area-diagnostics.js | 3 + scripts/lib/cesium-preview.js | 17 +- scripts/lib/lane-geometry.js | 161 +++++ scripts/lib/turn-lane-arrows.js | 18 +- scripts/lib/vehicle-route.js | 609 ++++++++++++++---- scripts/test-asset-budgets.js | 65 +- scripts/test-preview-assets.js | 244 ++++++- 17 files changed, 1081 insertions(+), 192 deletions(-) create mode 100644 scripts/lib/lane-geometry.js diff --git a/.trellis/spec/blender/asset-generation.md b/.trellis/spec/blender/asset-generation.md index 1922ab9..8931084 100644 --- a/.trellis/spec/blender/asset-generation.md +++ b/.trellis/spec/blender/asset-generation.md @@ -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/.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 材质索引整体平移 | diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md index b080f4e..d014c82 100644 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ b/.trellis/spec/guides/cross-layer-thinking-guide.md @@ -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 中有三个以上相连 way;osm2stree **教训**:**跨阶段运行时 JSON 必须在最早消费它的 stage 从当前权威产物重建;同时要 区分“初始化来源”和“编辑后的事实源”,不能用早期输入覆盖人工编辑。** +### 坑 7:GeoJSON 内部正确,但 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-坐标契约) + --- ## 加东西时的检查清单 diff --git a/.trellis/spec/pipeline/cli-and-stages.md b/.trellis/spec/pipeline/cli-and-stages.md index 25c2c6e..329e0e1 100644 --- a/.trellis/spec/pipeline/cli-and-stages.md +++ b/.trellis/spec/pipeline/cli-and-stages.md @@ -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` diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index e97694b..dd89bfc 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -112,6 +112,9 @@ setLoadingMessage("Preparing view") window.osmPreview = { viewer, metadata, placement, assets, cruise, cameras }; ``` +预览加载的生成式 JSON(路线和交通信号)使用 `fetch(..., { cache: "no-store" })`,因为 +这些文件保持稳定文件名但会被单独重生成;浏览器不得继续显示旧的巡航路线。 + 调试和无头检查都靠它。**加新的顶层对象就往这里挂**,不要再开新全局。 --- diff --git a/.trellis/spec/preview/vehicle-routes.md b/.trellis/spec/preview/vehicle-routes.md index 10191ac..8dda1a8 100644 --- a/.trellis/spec/preview/vehicle-routes.md +++ b/.trellis/spec/preview/vehicle-routes.md @@ -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/.json --stages preview`,确认 `routes` 中存在左、右、直动作,且 Cesium 下拉标签显示编号、长度与动作统计。 +- 修改地理投影时必须运行 `blender,cesium,preview`,不能只重跑 preview;最终检查青色路线 + 到黄色中心线及道路边缘的横截面距离,确认两侧路线分别位于各自车道中心。 ## 7. Wrong vs Correct diff --git a/README.md b/README.md index 4a3a575..71e2ff9 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ outputs//_pipeline/stages/compress.manifest.json manifest 记录阶段输入/输出文件的 bytes、mtime、sha256、耗时和结构摘要:前段记录 OSM / GeoJSON feature counts,Blender 记录 `.blend` / render,Cesium/压缩记录 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` 阶段会额外生成 `-vehicle-route.json` 和 `-vehicle-car.gltf`。路线文件从 OSM bounds 内的可行驶 `highway` way 提取道路中心线,并向右偏移约 1.3 米作为车辆行驶线,避免车辆压道路中心线。Cesium 预览页会加载多条道路段并显示多辆实验车辆循环巡航;`Vehicle` 下拉框决定 `Follow` 跟随哪一辆车。 +`preview` 和 `cesium` 阶段会额外生成 `-vehicle-route.json` 和 `-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 的轻量预览模型,生成在输出目录中。 diff --git a/blender/osmassets/osm.py b/blender/osmassets/osm.py index 897fcd4..75da50e 100644 --- a/blender/osmassets/osm.py +++ b/blender/osmassets/osm.py @@ -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 diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py index db5809d..0c8937a 100644 --- a/blender/tests/test_pure.py +++ b/blender/tests/test_pure.py @@ -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. diff --git a/scripts/build-area.js b/scripts/build-area.js index a930609..6b690c6 100755 --- a/scripts/build-area.js +++ b/scripts/build-area.js @@ -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}`); diff --git a/scripts/build-osm2streets-qgis.js b/scripts/build-osm2streets-qgis.js index 0208d79..f7e3cb7 100755 --- a/scripts/build-osm2streets-qgis.js +++ b/scripts/build-osm2streets-qgis.js @@ -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) diff --git a/scripts/lib/area-diagnostics.js b/scripts/lib/area-diagnostics.js index 94f29b4..1395b49 100644 --- a/scripts/lib/area-diagnostics.js +++ b/scripts/lib/area-diagnostics.js @@ -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"), }, diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index c73fe38..cefde37 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -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) { diff --git a/scripts/lib/lane-geometry.js b/scripts/lib/lane-geometry.js new file mode 100644 index 0000000..20d36bc --- /dev/null +++ b/scripts/lib/lane-geometry.js @@ -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, +}; diff --git a/scripts/lib/turn-lane-arrows.js b/scripts/lib/turn-lane-arrows.js index 39cfaf6..a243bd2 100644 --- a/scripts/lib/turn-lane-arrows.js +++ b/scripts/lib/turn-lane-arrows.js @@ -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); } diff --git a/scripts/lib/vehicle-route.js b/scripts/lib/vehicle-route.js index ea78d50..a6f6dd3 100644 --- a/scripts/lib/vehicle-route.js +++ b/scripts/lib/vehicle-route.js @@ -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, +}; diff --git a/scripts/test-asset-budgets.js b/scripts/test-asset-budgets.js index 22bd9e8..84a1c42 100644 --- a/scripts/test-asset-budgets.js +++ b/scripts/test-asset-budgets.js @@ -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."); diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index 4f2d646..ba731ec 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -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, ` @@ -25,11 +35,11 @@ fs.writeFileSync(osmPath, ` - + - + @@ -43,8 +53,44 @@ fs.writeFileSync(osmPath, ` `); -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))); +}