From 30846b6df90d86ffc160d4a05cacad6896d599ce Mon Sep 17 00:00:00 2001 From: que01 Date: Wed, 5 Aug 2026 14:58:24 +0800 Subject: [PATCH] feat(preview): add continuous vehicle turn routes --- .trellis/spec/preview/index.md | 1 + .trellis/spec/preview/vehicle-routes.md | 91 ++++ .../08-05-vehicle-turn-routes/check.jsonl | 1 + .../tasks/08-05-vehicle-turn-routes/design.md | 31 ++ .../08-05-vehicle-turn-routes/implement.jsonl | 1 + .../08-05-vehicle-turn-routes/implement.md | 10 + .../tasks/08-05-vehicle-turn-routes/prd.md | 49 +++ .../tasks/08-05-vehicle-turn-routes/task.json | 26 ++ scripts/lib/cesium-preview.js | 20 +- scripts/lib/vehicle-route.js | 393 ++++++++++++++---- scripts/test-preview-assets.js | 48 ++- 11 files changed, 573 insertions(+), 98 deletions(-) create mode 100644 .trellis/spec/preview/vehicle-routes.md create mode 100644 .trellis/tasks/08-05-vehicle-turn-routes/check.jsonl create mode 100644 .trellis/tasks/08-05-vehicle-turn-routes/design.md create mode 100644 .trellis/tasks/08-05-vehicle-turn-routes/implement.jsonl create mode 100644 .trellis/tasks/08-05-vehicle-turn-routes/implement.md create mode 100644 .trellis/tasks/08-05-vehicle-turn-routes/prd.md create mode 100644 .trellis/tasks/08-05-vehicle-turn-routes/task.json diff --git a/.trellis/spec/preview/index.md b/.trellis/spec/preview/index.md index 64513d7..f1881e4 100644 --- a/.trellis/spec/preview/index.md +++ b/.trellis/spec/preview/index.md @@ -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「实验:车辆巡航」节:面向使用者的说明 diff --git a/.trellis/spec/preview/vehicle-routes.md b/.trellis/spec/preview/vehicle-routes.md new file mode 100644 index 0000000..ecb0ce9 --- /dev/null +++ b/.trellis/spec/preview/vehicle-routes.md @@ -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/.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")); +``` diff --git a/.trellis/tasks/08-05-vehicle-turn-routes/check.jsonl b/.trellis/tasks/08-05-vehicle-turn-routes/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-05-vehicle-turn-routes/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-05-vehicle-turn-routes/design.md b/.trellis/tasks/08-05-vehicle-turn-routes/design.md new file mode 100644 index 0000000..aaa8883 --- /dev/null +++ b/.trellis/tasks/08-05-vehicle-turn-routes/design.md @@ -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 不在首版。 diff --git a/.trellis/tasks/08-05-vehicle-turn-routes/implement.jsonl b/.trellis/tasks/08-05-vehicle-turn-routes/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-05-vehicle-turn-routes/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. 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."} diff --git a/.trellis/tasks/08-05-vehicle-turn-routes/implement.md b/.trellis/tasks/08-05-vehicle-turn-routes/implement.md new file mode 100644 index 0000000..6d6fb7f --- /dev/null +++ b/.trellis/tasks/08-05-vehicle-turn-routes/implement.md @@ -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` 重建;在浏览器观察 + 普通与压缩预览的多车转弯。 diff --git a/.trellis/tasks/08-05-vehicle-turn-routes/prd.md b/.trellis/tasks/08-05-vehicle-turn-routes/prd.md new file mode 100644 index 0000000..e8e954d --- /dev/null +++ b/.trellis/tasks/08-05-vehicle-turn-routes/prd.md @@ -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` 过滤,但仍不允许逆行单行道。 diff --git a/.trellis/tasks/08-05-vehicle-turn-routes/task.json b/.trellis/tasks/08-05-vehicle-turn-routes/task.json new file mode 100644 index 0000000..6b81807 --- /dev/null +++ b/.trellis/tasks/08-05-vehicle-turn-routes/task.json @@ -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": {} +} \ No newline at end of file diff --git a/scripts/lib/cesium-preview.js b/scripts/lib/cesium-preview.js index 4a094a2..55af897 100644 --- a/scripts/lib/cesium-preview.js +++ b/scripts/lib/cesium-preview.js @@ -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]; } diff --git a/scripts/lib/vehicle-route.js b/scripts/lib/vehicle-route.js index b16a02f..a3148ca 100644 --- a/scripts/lib/vehicle-route.js +++ b/scripts/lib/vehicle-route.js @@ -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(/]*)\/?\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(/]*)>/g)) { + for (const match of xml.matchAll(/]*)\/?\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(/]*)>([\s\S]*?)<\/way>/g)) { const attrs = xmlAttrs(match[1]); const body = match[2]; const tags = {}; - for (const tagMatch of body.matchAll(/]*)\/?>/g)) { + for (const tagMatch of body.matchAll(/]*)\/?\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(/]*)\/?>/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(/]*)\/?\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(/]*)\/?>/); - 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 }; diff --git a/scripts/test-preview-assets.js b/scripts/test-preview-assets.js index cfbf9e3..fbcf974 100644 --- a/scripts/test-preview-assets.js +++ b/scripts/test-preview-assets.js @@ -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, ` - - - - - - + + + + + + - - + + + + + + + + + @@ -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");