feat(preview): add continuous vehicle turn routes
This commit is contained in:
@@ -306,4 +306,5 @@ python3 -m http.server 8765
|
||||
|
||||
- [CLI 与阶段](../pipeline/cli-and-stages.md):`cesium` / `preview` 阶段如何生成这些文件
|
||||
- [资产生成](../blender/asset-generation.md):GLB 里的材质为什么要单独调色
|
||||
- [车辆连续路线](vehicle-routes.md):路线 JSON、转向选择与预览标签契约
|
||||
- README「实验:车辆巡航」节:面向使用者的说明
|
||||
|
||||
91
.trellis/spec/preview/vehicle-routes.md
Normal file
91
.trellis/spec/preview/vehicle-routes.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 车辆连续路线
|
||||
|
||||
## 1. Scope / Trigger
|
||||
|
||||
适用于 `scripts/lib/vehicle-route.js` 生成的路线 JSON,以及
|
||||
`scripts/lib/cesium-preview.js` 对车辆巡航路线的读取与展示。
|
||||
|
||||
触发:修改路线生成、OSM 转向标签解析、车辆选择菜单或路线 JSON 字段时。
|
||||
路线仅用于 Cesium 验证预览,不构成交通仿真或法规级导航。
|
||||
|
||||
## 2. Signatures
|
||||
|
||||
```js
|
||||
buildVehicleRoute(osmPath) => {
|
||||
source, bounds, generatedAt, speedMetersPerSecond, loop,
|
||||
routes, segments
|
||||
}
|
||||
|
||||
allowedTurns(tags, direction) => Set<"left" | "through" | "right">
|
||||
classifyConnection(incomingEdge, outgoingEdge) =>
|
||||
"left" | "through" | "right" | "u_turn"
|
||||
```
|
||||
|
||||
浏览器运行时调用 `addVehicleCruises(viewer, routeData, vehicleModelName)`;它首先读取
|
||||
`routeData.routes`,仅在其不存在时回退到 `routeData.segments`。
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
- `routes` 是当前主字段;`segments` 必须是同一数组的兼容别名,供旧预览使用。
|
||||
- 每个路线至少包含 `id`、`coordinates`、`centerlineCoordinates`、`lengthMeters`、
|
||||
`maneuvers` 与 `edgeIds`。`coordinates` 是右侧车道偏移后的闭合巡航轨迹。
|
||||
- 有 `oneway=yes`(及等价真值)的 way 只能按 OSM 原始方向生成 edge,绝不能生成反向
|
||||
`:backward` edge;`oneway=-1` 仅允许反向 edge。
|
||||
- 去程在路口按入边方向读取 `turn:lanes:forward` 或 `turn:lanes:backward`,只有标签中的
|
||||
`left`、`through`、`right` 才是候选出口;无标签时允许这三类非 U-turn 动作。
|
||||
- 返程是展示路线的原路回返,不以反向 `turn:lanes` 再次否决,但依旧不可逆行单行道。
|
||||
- 路网没有闭环时,在去程和返程端点插入平滑调头曲线;不得在 way 端点或路口瞬移。
|
||||
- 选择菜单使用 `#编号 · 长度 m · 左 N / 右 N / 直 N`,因为一条路线可跨越多个道路名称。
|
||||
|
||||
## 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 结果 |
|
||||
|---|---|
|
||||
| 缺少或无法读取 route JSON | 预览继续加载,只取消巡航控件 |
|
||||
| `routes` 存在但为空 | 不回退到旧 `segments`;没有可播放车辆 |
|
||||
| 可行驶 way 少于两个节点或不在区域范围 | 不生成 edge |
|
||||
| 只存在反向单行可达路径 | 不生成违反单行限制的路线 |
|
||||
| 路口夹角接近掉头 | 分类为 `u_turn`,不作为去程出口 |
|
||||
| 候选路线不足五条 | 输出实际可用数量,预览按已有路线加载 |
|
||||
|
||||
## 5. Good/Base/Bad Cases
|
||||
|
||||
- 正常:树状道路网产生多条跨 way 往返路线,车辆经过左、右、直三种连接并在端点平滑掉头。
|
||||
- 基础:旧 JSON 只有 `segments` 时,预览仍能创建车辆与 Follow 控制。
|
||||
- 错误:对返程再次套用反向 `turn:lanes`,使原路返回在树状网络中被错误过滤。
|
||||
|
||||
## 6. Tests Required
|
||||
|
||||
- `node scripts/test-preview-assets.js`:断言路线闭合、端点调头、`turn:lanes` 拆分、左/右/直
|
||||
分类、单行道不逆行,以及 `segments === routes`。
|
||||
- `node --check scripts/lib/vehicle-route.js` 与
|
||||
`node --check scripts/lib/cesium-preview.js`:保证 Node 与浏览器直载脚本语法可用。
|
||||
- 对目标区域运行 `npm run build:area -- --config config/areas/<area>.json --stages preview`,确认
|
||||
`routes` 中存在左、右、直动作,且 Cesium 下拉标签显示编号、长度与动作统计。
|
||||
|
||||
## 7. Wrong vs Correct
|
||||
|
||||
错误:优先使用旧字段,导致新路线元数据无法被消费。
|
||||
|
||||
```js
|
||||
const segments = routeData.segments || routeData.routes || [];
|
||||
```
|
||||
|
||||
正确:新字段优先,旧字段仅作兼容回退。
|
||||
|
||||
```js
|
||||
const routes = routeData.routes || routeData.segments || [];
|
||||
```
|
||||
|
||||
错误:为使路线闭合而生成单行道路的反向 edge。
|
||||
|
||||
```js
|
||||
edges.push(makeEdge(way, refs.reverse(), coords.reverse(), "backward"));
|
||||
```
|
||||
|
||||
正确:单行仅保留其允许的方向,树状网络用端点调头闭合预览路线。
|
||||
|
||||
```js
|
||||
if (oneway !== "-1") edges.push(makeEdge(way, refs, coords, "forward"));
|
||||
if (!isOneWay(oneway)) edges.push(makeEdge(way, [...refs].reverse(), [...coords].reverse(), "backward"));
|
||||
```
|
||||
1
.trellis/tasks/08-05-vehicle-turn-routes/check.jsonl
Normal file
1
.trellis/tasks/08-05-vehicle-turn-routes/check.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
31
.trellis/tasks/08-05-vehicle-turn-routes/design.md
Normal file
31
.trellis/tasks/08-05-vehicle-turn-routes/design.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 设计:车辆连续巡航与转弯
|
||||
|
||||
## 数据与图模型
|
||||
|
||||
`vehicle-route.js` 继续只读取 OSM XML,但解析改为保留 node ID、坐标、way 标签和 node
|
||||
引用。每条可行驶 way 产生一个或两个有向 edge:`oneway=yes` 只保留原始方向,其他道路增加
|
||||
反向 edge。edge 的终点 node 连接其他以该 node 为起点的 edge。
|
||||
|
||||
入边在路口前的最后一个线段给出入射方位,出边第一个线段给出离去方位。二者的有符号夹角
|
||||
分类为 `left`、`through`、`right`;接近 180 度的 U-turn 一律排除。读取去程入边方向对应的
|
||||
`turn:lanes:forward/backward`,拆分 `|` 与 `;` 后得到允许动作集合。无 `turn:lanes` 的
|
||||
道路保守允许三种非 U-turn 动作,避免未标注区域没有路线。返程沿反向 edge 回走,不用反向
|
||||
`turn:lanes` 过滤;这是一项预览展示边界,不是交通规则模拟。
|
||||
|
||||
## 路线选择与几何
|
||||
|
||||
南台子湖主道路图是树状网络,不能生成真实 cycle。改为枚举连接两个端点、长度足够的有向
|
||||
道路路径,并按稳定的 road ID / maneuver 序列排序。选择彼此不完全相同、且合计覆盖
|
||||
left / through / right 的前 5 条。路径在两个端点各接一段平滑调头曲线,再沿反向道路返回;
|
||||
这使得路线可循环播放而不在端点瞬移。
|
||||
|
||||
每个路口连接把入边末段和出边首段裁去固定距离,用三次 Bezier 采样 6 个点衔接。偏移在
|
||||
整条连续路线完成后计算,避免每个 way 单独偏移在路口产生断裂。
|
||||
|
||||
route JSON 升级为 `routes`,每项有 `id`、`maneuvers`、`coordinates`、`lengthMeters`;同时
|
||||
继续写 `segments` 作为旧预览的兼容别名。Cesium 运行时优先读取 `routes`,退回 `segments`。
|
||||
|
||||
## 边界
|
||||
|
||||
没有 `restriction` relation 时不能声称交通法规完全正确。它只影响未来候选出口过滤,不改变
|
||||
路线格式或 Cesium 播放逻辑。路口曲线是视觉轨迹,车道级精确曲率与道路 polygon 不在首版。
|
||||
1
.trellis/tasks/08-05-vehicle-turn-routes/implement.jsonl
Normal file
1
.trellis/tasks/08-05-vehicle-turn-routes/implement.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
10
.trellis/tasks/08-05-vehicle-turn-routes/implement.md
Normal file
10
.trellis/tasks/08-05-vehicle-turn-routes/implement.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 实施计划:车辆连续巡航与转弯
|
||||
|
||||
1. 重构 `vehicle-route.js` 的 OSM 读取和纯几何 helper,构造有向 road graph、动作分类与
|
||||
`turn:lanes` 过滤。
|
||||
2. 枚举稳定的端点间道路路径、选择不重复且覆盖三种动作的至多 5 条路线;在端点添加
|
||||
平滑调头并生成路口连接。
|
||||
3. 扩展 route JSON,保留 `segments` 兼容字段;预览优先消费新路线数组,UI 标识路线及动作。
|
||||
4. 在 `test-preview-assets.js` 增加单行、标签过滤、左直右分类、闭环和曲线连续性夹具。
|
||||
5. 运行 Node 语法检查、预览测试、现有箭头测试和目标区域 `preview` 重建;在浏览器观察
|
||||
普通与压缩预览的多车转弯。
|
||||
49
.trellis/tasks/08-05-vehicle-turn-routes/prd.md
Normal file
49
.trellis/tasks/08-05-vehicle-turn-routes/prd.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 车辆连续巡航与转弯
|
||||
|
||||
## Goal
|
||||
|
||||
让 Cesium 预览中的车辆沿连续道路路线穿越路口,并以可见的平滑曲线完成左转、右转和直行;
|
||||
同屏展示多条确定性路线以核对效果。
|
||||
|
||||
## Confirmed Facts
|
||||
|
||||
- 当前 `scripts/lib/vehicle-route.js` 将每条可行驶 OSM way 独立导为折返巡航段;车辆不会跨
|
||||
way 行驶。
|
||||
- Cesium 预览最多加载 5 辆车辆,已有位置插值、朝向计算、路线可见性和 Follow 控制。
|
||||
- 南台子湖 OSM 使用单引号 XML 属性,含 15 处 `turn:lanes:forward`、15 处
|
||||
`turn:lanes:backward` 和 4 条 `oneway=yes`。
|
||||
- 没有 `restriction` relation;首版不能承诺处理禁止左转等限制关系。
|
||||
- 路口 `1140799725` 与 `1140799919` 各连接四条道路,另有多个三岔连接,足以形成多条
|
||||
左转、右转、直行的连续候选路线。
|
||||
|
||||
## Requirements
|
||||
|
||||
1. 从 OSM 可行驶 way 构建有向道路图:单行道只能按 tag 方向通行,双向道路提供两个方向。
|
||||
2. 在共享端点连接道路,依据入射和出射方向将候选动作分类为 left、through、right;去程
|
||||
只有动作出现在该方向 `turn:lanes:*` 的任一车道时才可通行。返程原路返回,不用反向
|
||||
`turn:lanes` 二次否决路线。
|
||||
3. 确定性地产生至多 5 条连续往返路线,覆盖至少一条左转、一条右转和一条直行路线。
|
||||
4. 路口与路线端点均用平滑连接补充路线点;车辆位置、朝向和可见路线必须连续,不能在
|
||||
way 端点跳回起点。
|
||||
5. 保持现有路线 JSON 是预览的可选输入,旧 route JSON 仍可由预览加载。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不做实时交通仿真、避碰、红绿灯、变道或速度控制。
|
||||
- 不使用 QGIS 图层或改动 Blender/GLB。
|
||||
- 不支持 OSM `restriction` relation;后续数据具备时再接入。
|
||||
- 不承诺为每条车道生成独立精确轨迹;`turn:lanes` 首版用于许可转向过滤。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 输出最多 5 条连续往返路线,且每条均有多个跨 way 的路口连接和端点平滑调头。
|
||||
- [ ] 南台子湖预览同屏可见左转、右转、直行三类路口动作,车辆不在路口或 way 端点瞬移。
|
||||
- [ ] `oneway=yes` 的路段不会逆向进入;去程没有匹配 `turn:lanes` 动作的出口不会被选入路线。
|
||||
- [ ] 路线生成对同一 OSM 输入稳定,自动化测试覆盖单行、动作过滤、转向分类、曲线连接和闭环。
|
||||
- [ ] 旧 route JSON 的预览兼容性不回归,现有 Pause、Follow、路线开关和车辆选择仍可用。
|
||||
|
||||
## Open Questions
|
||||
|
||||
已决:南台子湖可行驶道路主连通分量是树状网络(23 个端点节点、19 条道路连接),没有
|
||||
真实闭环;首版以端点平滑调头的往返路线替代闭环。为展示多条路线,返程不以反向
|
||||
`turn:lanes` 过滤,但仍不允许逆行单行道。
|
||||
26
.trellis/tasks/08-05-vehicle-turn-routes/task.json
Normal file
26
.trellis/tasks/08-05-vehicle-turn-routes/task.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "vehicle-turn-routes",
|
||||
"name": "vehicle-turn-routes",
|
||||
"title": "车辆连续巡航与转弯",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "dingkang",
|
||||
"assignee": "dingkang",
|
||||
"createdAt": "2026-08-05",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -423,7 +423,7 @@
|
||||
}
|
||||
|
||||
function addVehicleCruises(viewer, routeData, vehicleModelName) {
|
||||
const segments = ((routeData && routeData.segments) || [])
|
||||
const segments = ((routeData && (routeData.routes || routeData.segments)) || [])
|
||||
.filter((segment) => segment.coordinates && segment.coordinates.length >= 2)
|
||||
.slice(0, 5);
|
||||
const speed = Number((routeData && routeData.speedMetersPerSecond) || 8);
|
||||
@@ -439,7 +439,7 @@
|
||||
const vehicle = addCruiseVehicle(viewer, segment, index, start, speed, vehicleModelName);
|
||||
const option = document.createElement("option");
|
||||
option.value = String(index);
|
||||
option.textContent = "#" + (index + 1) + " " + segment.name + " " + Math.round(segment.lengthMeters) + "m";
|
||||
option.textContent = routeLabel(segment, index);
|
||||
vehicleSelect.appendChild(option);
|
||||
return vehicle;
|
||||
});
|
||||
@@ -493,10 +493,24 @@
|
||||
positions,
|
||||
route,
|
||||
segment,
|
||||
label: "Vehicle #" + (index + 1) + " | route " + segment.id + " | " + Math.round(segment.lengthMeters) + "m"
|
||||
label: routeLabel(segment, index),
|
||||
};
|
||||
}
|
||||
|
||||
function routeLabel(route, index) {
|
||||
const counts = { left: 0, right: 0, through: 0 };
|
||||
for (const maneuver of route.maneuvers || []) {
|
||||
if (maneuver in counts) counts[maneuver] += 1;
|
||||
}
|
||||
const actions = [
|
||||
counts.left ? "左 " + counts.left : "",
|
||||
counts.right ? "右 " + counts.right : "",
|
||||
counts.through ? "直 " + counts.through : "",
|
||||
].filter(Boolean).join(" / ");
|
||||
return "#" + (index + 1) + " · " + Math.round(route.lengthMeters || 0) + " m" +
|
||||
(actions ? " · " + actions : "");
|
||||
}
|
||||
|
||||
function selectedVehicle(cruise) {
|
||||
return cruise.vehicles[cruise.state.selectedIndex] || cruise.vehicles[0];
|
||||
}
|
||||
|
||||
@@ -2,66 +2,69 @@
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const MAX_ROUTES = 5;
|
||||
const MAX_PATH_EDGES = 7;
|
||||
const MIN_ROUTE_EDGES = 3;
|
||||
const LANE_OFFSET_METERS = 1.3;
|
||||
const JUNCTION_TRIM_METERS = 6.0;
|
||||
const ALL_TURNS = new Set(["left", "through", "right"]);
|
||||
|
||||
function buildVehicleRoute(osmPath) {
|
||||
const xml = fs.readFileSync(osmPath, "utf8");
|
||||
const bounds = osmBounds(xml);
|
||||
const osm = parseOsm(fs.readFileSync(osmPath, "utf8"));
|
||||
const edges = directedRoadEdges(osm.ways, osm.nodes, osm.bounds);
|
||||
const routes = selectRoutes(findReturnRoutes(edges));
|
||||
return {
|
||||
source: osmPath,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOsm(xml) {
|
||||
const boundsMatch = xml.match(/<bounds\b([^>]*)\/?\s*>/);
|
||||
const boundsAttrs = boundsMatch ? xmlAttrs(boundsMatch[1]) : {};
|
||||
const bounds = {
|
||||
minLon: Number(boundsAttrs.minlon), minLat: Number(boundsAttrs.minlat),
|
||||
maxLon: Number(boundsAttrs.maxlon), maxLat: Number(boundsAttrs.maxlat),
|
||||
};
|
||||
const validBounds = Object.values(bounds).every(Number.isFinite) ? bounds : null;
|
||||
const nodes = new Map();
|
||||
for (const match of xml.matchAll(/<node\b([^>]*)>/g)) {
|
||||
for (const match of xml.matchAll(/<node\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
if (!attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
||||
nodes.set(attrs.id, [Number(attrs.lon), Number(attrs.lat)]);
|
||||
const coord = [Number(attrs.lon), Number(attrs.lat)];
|
||||
if (coord.every(Number.isFinite)) nodes.set(attrs.id, coord);
|
||||
}
|
||||
const segments = [];
|
||||
const ways = [];
|
||||
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const body = match[2];
|
||||
const tags = {};
|
||||
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?>/g)) {
|
||||
for (const tagMatch of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
|
||||
const tag = xmlAttrs(tagMatch[1]);
|
||||
if (tag.k) tags[tag.k] = tag.v || "";
|
||||
}
|
||||
if (!isCruiseHighway(tags)) continue;
|
||||
const coords = [];
|
||||
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?>/g)) {
|
||||
const nd = xmlAttrs(ndMatch[1]);
|
||||
const coord = nodes.get(nd.ref);
|
||||
if (coord) coords.push(coord);
|
||||
}
|
||||
const runs = splitInBounds(compactCoords(coords), bounds);
|
||||
let runIndex = 0;
|
||||
for (const run of runs) {
|
||||
const lengthMeters = routeLength(run);
|
||||
if (lengthMeters < 20) continue;
|
||||
runIndex += 1;
|
||||
const laneOffsetMeters = 1.3;
|
||||
segments.push({
|
||||
id: runIndex === 1 ? (attrs.id || `way-${segments.length + 1}`) : `${attrs.id || "way"}-${runIndex}`,
|
||||
name: tags.name || tags.highway || "road",
|
||||
highway: tags.highway || "",
|
||||
oneWay: tags.oneway || "",
|
||||
lengthMeters,
|
||||
laneOffsetMeters,
|
||||
coordinates: offsetPolylineRight(run, laneOffsetMeters),
|
||||
centerlineCoordinates: run,
|
||||
});
|
||||
const refs = [];
|
||||
for (const ndMatch of body.matchAll(/<nd\b([^>]*)\/?\s*>/g)) {
|
||||
const ref = xmlAttrs(ndMatch[1]).ref;
|
||||
if (ref && nodes.has(ref)) refs.push(ref);
|
||||
}
|
||||
if (refs.length >= 2) ways.push({ id: attrs.id || `way-${ways.length + 1}`, refs, tags });
|
||||
}
|
||||
segments.sort((a, b) => b.lengthMeters - a.lengthMeters);
|
||||
return { source: osmPath, bounds, generatedAt: new Date().toISOString(), speedMetersPerSecond: 8.0, loop: true, segments };
|
||||
}
|
||||
|
||||
function osmBounds(xml) {
|
||||
const match = xml.match(/<bounds\b([^>]*)\/?>/);
|
||||
if (!match) return null;
|
||||
const attrs = xmlAttrs(match[1]);
|
||||
const bounds = { minLon: Number(attrs.minlon), minLat: Number(attrs.minlat), maxLon: Number(attrs.maxlon), maxLat: Number(attrs.maxlat) };
|
||||
return Object.values(bounds).every(Number.isFinite) ? bounds : null;
|
||||
return { bounds: validBounds, nodes, ways };
|
||||
}
|
||||
|
||||
function xmlAttrs(text) {
|
||||
const attrs = {};
|
||||
for (const match of text.matchAll(/([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
||||
attrs[match[1]] = match[3] !== undefined ? match[3] : match[4];
|
||||
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
||||
attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
|
||||
}
|
||||
return attrs;
|
||||
}
|
||||
@@ -69,20 +72,272 @@ function xmlAttrs(text) {
|
||||
function isCruiseHighway(tags) {
|
||||
const highway = tags.highway || "";
|
||||
if (!highway || tags.area === "yes") return false;
|
||||
return !new Set(["footway", "path", "pedestrian", "steps", "cycleway", "service", "track", "bridleway", "corridor", "elevator", "platform", "construction"]).has(highway);
|
||||
return !new Set([
|
||||
"footway", "path", "pedestrian", "steps", "cycleway", "service", "track",
|
||||
"bridleway", "corridor", "elevator", "platform", "construction",
|
||||
]).has(highway);
|
||||
}
|
||||
|
||||
function compactCoords(coords) {
|
||||
const out = [];
|
||||
for (const coord of coords) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || last[0] !== coord[0] || last[1] !== coord[1]) out.push(coord);
|
||||
function directedRoadEdges(ways, nodes, bounds) {
|
||||
const edges = [];
|
||||
for (const way of ways) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return edges.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
function makeEdge(way, refs, coordinates, direction) {
|
||||
return {
|
||||
id: `${way.id}:${direction}`,
|
||||
wayId: way.id,
|
||||
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],
|
||||
coordinates,
|
||||
allowedTurns: allowedTurns(way.tags, direction),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
return turns.size ? turns : ALL_TURNS;
|
||||
}
|
||||
|
||||
function findReturnRoutes(edges) {
|
||||
const outgoing = new Map();
|
||||
const byId = new Map();
|
||||
for (const edge of edges) {
|
||||
if (!outgoing.has(edge.startNode)) outgoing.set(edge.startNode, []);
|
||||
outgoing.get(edge.startNode).push(edge);
|
||||
byId.set(edge.id, edge);
|
||||
}
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
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];
|
||||
if (path.length >= MIN_ROUTE_EDGES) {
|
||||
const route = returnRoute(path, maneuvers, byId);
|
||||
if (route && !seen.has(route.signature)) {
|
||||
seen.add(route.signature);
|
||||
candidates.push(route);
|
||||
}
|
||||
}
|
||||
if (path.length >= MAX_PATH_EDGES) return;
|
||||
const nextSteps = [];
|
||||
for (const next of outgoing.get(current.endNode) || []) {
|
||||
if (path.some((edge) => edge.id === next.id)) continue;
|
||||
const maneuver = classifyConnection(current, next);
|
||||
if (!maneuver || !current.allowedTurns.has(maneuver)) continue;
|
||||
nextSteps.push({ edge: next, maneuver });
|
||||
}
|
||||
for (const step of nextSteps) {
|
||||
walkToTerminal([...path, step.edge], [...maneuvers, step.maneuver], outgoing, byId, candidates, seen);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyConnection(incoming, outgoing) {
|
||||
if (incoming.wayId === outgoing.wayId) 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;
|
||||
const cross = inVector.x * outVector.y - inVector.y * outVector.x;
|
||||
const angle = Math.atan2(cross, dot) * 180 / Math.PI;
|
||||
if (Math.abs(angle) >= 150) return null;
|
||||
if (Math.abs(angle) <= 35) return "through";
|
||||
return angle > 0 ? "left" : "right";
|
||||
}
|
||||
|
||||
function directionVector(a, b) {
|
||||
const scale = 111320.0;
|
||||
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;
|
||||
return { x: x / length, y: y / length };
|
||||
}
|
||||
|
||||
function returnRoute(path, forwardManeuvers, byId) {
|
||||
const reverse = path.slice().reverse().map((edge) => byId.get(`${edge.wayId}:${oppositeDirection(edge.direction)}`));
|
||||
if (reverse.some((edge) => !edge)) return null;
|
||||
const returnManeuvers = [];
|
||||
for (let index = 1; index < reverse.length; index += 1) {
|
||||
const maneuver = classifyConnection(reverse[index - 1], reverse[index]);
|
||||
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"],
|
||||
signature,
|
||||
);
|
||||
}
|
||||
|
||||
function oppositeDirection(direction) {
|
||||
return direction === "forward" ? "backward" : "forward";
|
||||
}
|
||||
|
||||
function makeRoute(edges, maneuvers, signature) {
|
||||
const coordinates = smoothRoute(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,
|
||||
lengthMeters: routeLength(coordinates),
|
||||
laneOffsetMeters: LANE_OFFSET_METERS,
|
||||
coordinates: offsetClosedRouteRight(coordinates, LANE_OFFSET_METERS),
|
||||
centerlineCoordinates: coordinates,
|
||||
};
|
||||
Object.defineProperty(route, "signature", { value: signature });
|
||||
return route;
|
||||
}
|
||||
|
||||
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);
|
||||
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 offsetCoordinate(coord, eastMeters, northMeters) {
|
||||
const metersPerLat = 111320.0;
|
||||
const metersPerLon = metersPerLat * Math.cos(degreesToRadians(coord[1]));
|
||||
return [coord[0] + eastMeters / metersPerLon, coord[1] + northMeters / metersPerLat];
|
||||
}
|
||||
|
||||
function trimPolyline(coords, distance) {
|
||||
if (coords.length < 2 || routeLength(coords) <= distance * 2.5) return [...coords];
|
||||
const start = pointAlong(coords, distance);
|
||||
const end = pointAlong([...coords].reverse(), distance);
|
||||
return [start, ...coords.slice(1, -1), end];
|
||||
}
|
||||
|
||||
function pointAlong(coords, distance) {
|
||||
let remaining = distance;
|
||||
for (let index = 1; index < coords.length; index += 1) {
|
||||
const span = haversineMeters(coords[index - 1], coords[index]);
|
||||
if (span >= remaining) return lerpCoordinate(coords[index - 1], coords[index], remaining / span);
|
||||
remaining -= span;
|
||||
}
|
||||
return [...coords.at(-1)];
|
||||
}
|
||||
|
||||
function bezierTurn(start, junction, end, samples) {
|
||||
const controlA = lerpCoordinate(start, junction, 0.72);
|
||||
const controlB = lerpCoordinate(end, junction, 0.72);
|
||||
const points = [];
|
||||
for (let index = 0; index <= samples; index += 1) {
|
||||
const t = index / samples;
|
||||
const u = 1 - t;
|
||||
points.push([
|
||||
u ** 3 * start[0] + 3 * u ** 2 * t * controlA[0] + 3 * u * t ** 2 * controlB[0] + t ** 3 * end[0],
|
||||
u ** 3 * start[1] + 3 * u ** 2 * t * controlA[1] + 3 * u * t ** 2 * controlB[1] + t ** 3 * end[1],
|
||||
]);
|
||||
}
|
||||
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();
|
||||
const remaining = [...candidates];
|
||||
while (selected.length < MAX_ROUTES && remaining.length) {
|
||||
remaining.sort((a, b) => routeScore(b, covered) - routeScore(a, covered) || a.id.localeCompare(b.id));
|
||||
const next = remaining.shift();
|
||||
selected.push(next);
|
||||
for (const maneuver of next.maneuvers) covered.add(maneuver);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function routeScore(route, covered) {
|
||||
const novelty = new Set(route.maneuvers.filter((maneuver) => ALL_TURNS.has(maneuver) && !covered.has(maneuver))).size;
|
||||
return novelty * 100000 + route.lengthMeters;
|
||||
}
|
||||
|
||||
function offsetPolylineRight(coords, offsetMeters) {
|
||||
if (coords.length < 2 || offsetMeters === 0) return coords;
|
||||
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));
|
||||
@@ -90,39 +345,17 @@ function offsetPolylineRight(coords, offsetMeters) {
|
||||
return points.map((point, index) => {
|
||||
const prev = points[Math.max(0, index - 1)];
|
||||
const next = points[Math.min(points.length - 1, index + 1)];
|
||||
let dx = next.x - prev.x;
|
||||
let dy = next.y - prev.y;
|
||||
const length = Math.hypot(dx, dy);
|
||||
const length = Math.hypot(next.x - prev.x, next.y - prev.y);
|
||||
if (length < 0.001) return [point.lon, point.lat];
|
||||
dx /= length;
|
||||
dy /= length;
|
||||
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 splitInBounds(coords, bounds) {
|
||||
if (!bounds) return [coords];
|
||||
const runs = [];
|
||||
let current = [];
|
||||
for (const coord of coords) {
|
||||
if (insideBounds(coord, bounds)) current.push(coord);
|
||||
else if (current.length) {
|
||||
if (current.length >= 2) runs.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
if (current.length >= 2) runs.push(current);
|
||||
return runs;
|
||||
}
|
||||
|
||||
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 routeLength(coords) {
|
||||
let total = 0;
|
||||
for (let i = 1; i < coords.length; i += 1) total += haversineMeters(coords[i - 1], coords[i]);
|
||||
for (let index = 1; index < coords.length; index += 1) total += haversineMeters(coords[index - 1], coords[index]);
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -132,12 +365,14 @@ function haversineMeters(a, b) {
|
||||
const lat2 = degreesToRadians(b[1]);
|
||||
const dLat = degreesToRadians(b[1] - a[1]);
|
||||
const dLon = degreesToRadians(b[0] - a[0]);
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLon = Math.sin(dLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||
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)));
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
module.exports = { buildVehicleRoute };
|
||||
module.exports = { buildVehicleRoute, classifyConnection, allowedTurns };
|
||||
|
||||
@@ -7,7 +7,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { cesiumPreviewHtml } = require("./lib/area-preview");
|
||||
const { makeVehicleGltf } = require("./lib/vehicle-model");
|
||||
const { buildVehicleRoute } = require("./lib/vehicle-route");
|
||||
const { allowedTurns, buildVehicleRoute, classifyConnection } = require("./lib/vehicle-route");
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preview-assets-"));
|
||||
const osmPath = path.join(tempDir, "fixture.osm");
|
||||
@@ -17,15 +17,22 @@ fs.writeFileSync(osmPath, `<?xml version="1.0"?>
|
||||
<bounds minlon="120" minlat="30" maxlon="120.01" maxlat="30.01"/>
|
||||
<node id="1" lon="120.001" lat="30.001"/>
|
||||
<node id="2" lon="120.003" lat="30.001"/>
|
||||
<node id="3" lon="120.006" lat="30.001"/>
|
||||
<node id="4" lon="120.001" lat="30.003"/>
|
||||
<node id="5" lon="120.001" lat="30.007"/>
|
||||
<way id="major-road">
|
||||
<nd ref="1"/><nd ref="2"/><nd ref="3"/>
|
||||
<tag k="highway" v="primary"/><tag k="name" v="Major Road"/><tag k="oneway" v="yes"/>
|
||||
<node id="3" lon="120.005" lat="30.002"/>
|
||||
<node id="4" lon="120.007" lat="30.002"/>
|
||||
<node id="5" lon="120.009" lat="30.002"/>
|
||||
<way id="west-road">
|
||||
<nd ref="1"/><nd ref="2"/>
|
||||
<tag k="highway" v="primary"/><tag k="turn:lanes:forward" v="left|through"/>
|
||||
</way>
|
||||
<way id="minor-road">
|
||||
<nd ref="4"/><nd ref="5"/><tag k="highway" v="residential"/>
|
||||
<way id="turn-road">
|
||||
<nd ref="2"/><nd ref="3"/>
|
||||
<tag k="highway" v="residential"/><tag k="turn:lanes:forward" v="through|left"/>
|
||||
</way>
|
||||
<way id="east-road">
|
||||
<nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/>
|
||||
</way>
|
||||
<way id="oneway-spur">
|
||||
<nd ref="4"/><nd ref="5"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/>
|
||||
</way>
|
||||
<way id="footpath">
|
||||
<nd ref="1"/><nd ref="5"/><tag k="highway" v="footway"/>
|
||||
@@ -43,13 +50,22 @@ assert.deepEqual(route.bounds, {
|
||||
});
|
||||
assert.equal(route.speedMetersPerSecond, 8);
|
||||
assert.equal(route.loop, true);
|
||||
assert.equal(route.segments.length, 2);
|
||||
assert.deepEqual(route.segments.map((segment) => segment.id), ["major-road", "minor-road"]);
|
||||
assert.equal(route.segments[0].name, "Major Road");
|
||||
assert.equal(route.segments[0].oneWay, "yes");
|
||||
assert.equal(route.segments[0].laneOffsetMeters, 1.3);
|
||||
assert.notDeepEqual(route.segments[0].coordinates, route.segments[0].centerlineCoordinates);
|
||||
assert.ok(route.segments[0].lengthMeters > route.segments[1].lengthMeters);
|
||||
assert.equal(route.segments, route.routes, "legacy segments remains an alias for the new route list");
|
||||
assert.ok(route.routes.length >= 1 && route.routes.length <= 5);
|
||||
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.notDeepEqual(route.routes[0].coordinates, route.routes[0].centerlineCoordinates);
|
||||
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 vehicle = makeVehicleGltf();
|
||||
assert.equal(vehicle.asset.version, "2.0");
|
||||
|
||||
Reference in New Issue
Block a user