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)));
+}