当航线遇上禁飞区:Turf.js 自动绕行算法与 Cesium 对比演示
引言:航线规划的最后一张拼图
上一篇我们把「镜头参数 → GSD → 扫描线航线」的链路走通了:给一块测区多边形,算法自动铺出蛇形航段。但真实空域从来不是净土——机场净空区、军事管理区、人口稠密区的禁飞区像礁石一样散布在测区里,任务书最后一行往往是:「以上区域不得进入,航线自行绕避」。
最朴素的思路是逐段替换:扫描线穿过禁飞区的部分,直接换成沿禁飞区边界的绕行线。看似直观,实则在犯”贴边打转”的错误——飞行器会在禁飞区两侧来回横跳,每跨一条扫描线就沿边界兜一次半圈。对一架作业时间按分钟计费的工业无人机来说,这是不能接受的浪费。
想想扫地机器人:它从不贴着障碍物边缘来回蹭,而是跳过障碍继续扫,扫完一侧再换另一侧。本文要讲的正是这个策略的算法化——丢弃 + 重串:
- 丢弃:把落在禁飞区内的航段整个扔掉,只保留禁飞区外的有效航段;
- 重串:以最小连接代价把有效航段重新串联成一条完整航线。
关键收益只有一个数字:边界绕行次数从「航段数量级」降为「侧数量级」。同侧航段之间的衔接只花一个航线间距,只有跨侧转移才走一次边界——三十六段航段、两侧布局的场景下,绕行次数从 18 次降到 1 次。
一、逐段绕行为什么不行
把问题画出来。三条扫描线被禁飞区切成六段,蛇形原始顺序如下(编号即飞行次序):
蛇形原始顺序(███ 为禁飞区):
①━━━━━━━╗ ███ ╔━━━━━━━② 扫描线 1(左 → 右)
║ ███ ║
④━━━━━━━╣ ███ ╠━━━━━━━③ 扫描线 2(右 → 左)
║ ███ ║
⑤━━━━━━━╝ ███ ╚━━━━━━━⑥ 扫描线 3(左 → 右)
逐段绕行: ①─绕→② ③─绕→④ ⑤─绕→⑥
3 次穿越替换成 3 次边界绕行,绕行侧交替(上、下、下),
飞行器在禁飞区两侧来回横跳,边界上反复往返
丢弃 + 重串:① → ④ → ⑤ ─绕一次→ ⑥ → ③ → ②
左侧三段以一个航线间距纵向衔接,跨侧只绕行一次
逐段绕行的绕行次数随穿越次数线性增长——扫描线每加密一倍,绕行次数翻倍;而丢弃 + 重串的绕行次数只随侧数增长——扫描线加密到一百条,左右两侧的布局不变,绕行依旧一次。
代价也是有的:重串后的航线不再是整齐的蛇形,同侧航段的纵向衔接会跨越多条扫描线的间距。对测绘任务这是可接受的——衔接段不拍照,多飞几十米远比在边界上兜圈子便宜。
二、算法总览:六步执行链路
2.1 数据准备与一个静默陷阱
禁飞区数据来自业务侧(多边形顶点),进入算法前先做一次安全缓冲:
const nfzSaveDistance = 20 // 禁飞区 20 m 缓冲区增加容错
const coordinates = [
[120.62630618691334, 31.316518744934303],
[120.6258483102708, 31.3160230189197],
[120.62656376496544, 31.315934698823227],
[120.62701226573458, 31.316488940604415],
[120.62630618691334, 31.316518744934303] // GeoJSON 环要求首尾闭合
]
// 注意:turf.buffer 返回单个 Feature,而算法契约要求 Feature 数组
// (内部要做 .length 判断与 .forEach 遍历)——必须包一层数组。
// 这是最容易踩的静默坑:不包数组时 nfzCollection.length 为 undefined,
// !undefined 恒为真,算法在入口就把航线原样返回,绕行永不生效且无任何报错。
const nfzCollection = [turf.buffer(turf.polygon(coordinates), nfzSaveDistance, { units: 'meters', steps: 5 })]
这个「忘了包数组」的坑值得单独写一段:它不抛错、不警告,只是让算法安静地失效。地理计算库的鸭子类型接口(Feature 与 FeatureCollection 都能被多数 turf 函数接受)放大了这类契约不一致的隐蔽性——入口处先断言数据形态,比在五脏六腑里查 bug 便宜得多。
2.2 单位与版本约定
全文代码基于 Turf.js 7.4.0,三件事先说清楚,后文不再重复:
- 距离单位:
turf.distance/turf.length/nearestPointOnLine默认单位都是千米,所有调用必须显式传{ units: 'meters' }——漏传一处,阈值判断就差三个数量级。 turf.union签名:turf 7 接受FeatureCollection单参数;turf 6 及以前是变长参数union(polyA, polyB, …)。跨大版本迁移时这是必改点。turf.polygonToLine返回形态:turf 7 对简单多边形返回单个 Feature,turf 6 返回 FeatureCollection(详见上一篇的踩坑记录)。
另有一条换算铁律:经纬度不是线性度量,纬度方向每度约 111,320 米(M_PER_DEG_LAT),经度方向随纬度收缩——凡涉及「米 ↔ 度」的换算,只对纬度成立。
2.3 执行链路与函数依赖
输入 waylinePoints(扫描线生成的原始航点)
│
├── 无禁飞区 ─────────────────────────────► 原样返回
▼
① 障碍物预处理 · buildObstacles
│ 每个禁飞区外环派生:判定面(内缩 0.5 m,贴边飞行不算穿越)
│ + 绕行环(外扩 0.5 m,绕行走线基准)
│ 另备 strictNfz:禁飞区整体内缩 5 m,作为 ⑥ 的兜底判定面
▼
② 航线分割 · turf.lineSplit(多禁飞区先 union 成整体,避免缝隙不可绕)
│ 分割失败 / 0 段(未触及或被完全覆盖)──► 兜底 ⑥
▼
③ 有效航段筛选
│ 保留:≥ 2 点 ∧ 实长 > 1 m ∧ 弧长中点不在禁飞区内
│ (中点判定避开 lineSplit 切点落在边界上的误判)
│ 一段不剩 ─────────────────────────────► 兜底 ⑥
▼
④ 贪心重串 · linkFreeSegments
│ 每轮在「剩余航段 × 首尾两端」中选连接代价最小者接入
│ · 排序:estimateConnectionCost = 直线距离 + 被穿越障碍的短侧边界弧长
│ · 连接:buildSafePath —— 直线可通走直线,穿越障碍沿绕行环走短侧弧
│ → 同侧航段自然先衔接,绕行次数由航段数量级降为侧数量级
▼
⑤ 距离抽稀 · simplifyCoordinates
│ 与上一保留点距离 < 10 m 的点丢弃,强制保留航线末点
▼
⑥ 兜底去交 · removeIntersectingPoints
│ 逐段检查与 strictNfz 的相交性,剔除残留的穿越连线
▼
输出 绕行后的航点序列
对应六个函数家族,依赖关系如下——getAutoWayline 是纯编排层,重活全部下沉:
getAutoWayline(主入口 · 编排)
├── buildObstacles ① 障碍物预处理
├── isInsideNfz ── getPathLength ③ 有效航段筛选
├── linkFreeSegments ④ 贪心串联(调度核心)
│ ├── estimateConnectionCost ── isPathBlocked ④ 连接代价(仅排序 + 剪枝)
│ ├── buildSafePath ── isPathBlocked ④ 实际连接路径(递归展开)
│ │ └── getBoundaryArc ④ 边界绕行弧(getPathLength)
│ └── appendCoordinates ④ 拼接去重
├── simplifyCoordinates ⑤ 距离抽稀
└── removeIntersectingPoints ⑥ 兜底去交
三、逐步拆解:六个环节的设计决策
3.1 障碍物预处理:两级缓冲与保守取外环
角色:把业务侧的禁飞区多边形翻译成算法友好的「障碍物」结构。输入禁飞区要素数组,输出每个障碍的三件套——判定面(穿越判定)、绕行环(走线基准)、环长(弧长计算基础)。
function buildObstacles(nfzCollection) {
const turf = window.turf
const obstacles = []
nfzCollection.forEach(feature => {
turf.flatten(feature).features.forEach(item => {
// coordinates[0] 为外环,其余为洞,只取外环
const outer = turf.polygon([item.geometry.coordinates[0]])
const blockPolygon = turf.buffer(outer, -EDGE_TOLERANCE, { units: 'meters', steps: 2 })
const walkPolygon = turf.buffer(outer, EDGE_TOLERANCE, { units: 'meters', steps: 2 })
const ring = turf.lineString((walkPolygon?.geometry?.coordinates?.length ? walkPolygon : outer).geometry.coordinates[0])
obstacles.push({
polygon: blockPolygon?.geometry?.coordinates?.length ? blockPolygon : outer,
ring,
ringLength: turf.length(ring, { units: 'meters' })
})
})
})
return obstacles
}
三个设计决策:
- 两级缓冲协作。数据准备阶段的 20 m 缓冲是「安全容错」(坐标误差、定位漂移);算法内部对每个外环再内缩 0.5 m 得到判定面——穿越判定基于它,等于给「贴边飞行」留了 0.5 m 的豁免:与边界相切、沿边界滑过的连线不算穿越。对应地外扩 0.5 m 得到绕行环,绕行路径与真实边界之间永远隔着容差。一个障碍物、两套几何,判定从严(内缩)、走线从宽(外扩),互不干扰。
- 保守取外环。
flatten把 buffer 可能产生的 MultiPolygon 拆成单面后,每个面只取coordinates[0]外环,洞一律视为实心不可飞。真实禁飞区不会有「环形可飞带」这种操作,保守处理让booleanPointInPolygon的语义简单可靠。 steps: 2压顶点。turf.buffer 的 steps 参数控制圆角细分精度,默认 8 会在每个拐角生成大量顶点;这里取 2 刻意把缓冲多边形做「方」,后续弧长计算与抽稀的负担都小一个量级。多边形退化到 buffer 返回 null 时(内缩吃光面积),退回原始外环——宁可判定严一点,不能让障碍物凭空消失。
3.2 航线分割:union 的必要性与退化兜底
角色:用 turf.lineSplit 把原始航线按禁飞区切成若干段——禁飞区内的段和禁飞区外的段。这一步把「哪里不能飞」翻译成「哪些段能飞」。
let splitLine
try {
const splitter = nfzfeatureCollection.features.length > 1
? turf.union(nfzfeatureCollection) // turf 7:FeatureCollection 单参数
: nfzfeatureCollection.features[0]
splitLine = turf.lineSplit(waylineLineString, splitter)
} catch (e) {
return fallback() // union / lineSplit 遇到退化几何可能抛错,走兜底
}
if (splitLine.features.length === 0) {
return fallback() // 未触及禁飞区,或被禁飞区完全覆盖
}
两个容易忽略的点:
- 多禁飞区必须先 union。若不合并而逐个分割,两个相邻禁飞区之间的窄缝会被切成一段「自由航段」——它四面被禁飞区包围,飞行器进得去、出不来。合并后缝隙消失,分割结果天然不含这种陷阱段。(turf 7 的
union收 FeatureCollection;turf 6 是变长参数,迁移时注意。) splitLine.features.length === 0有两种含义:航线完全没碰到禁飞区(本就无需绕行),或航线被禁飞区完全覆盖(已无可飞的段)。两者都交给兜底函数处理——前者清洗后原样返回,后者清洗后只剩孤点。
3.3 有效航段筛选:为什么用弧长中点判定
角色:从分割结果中筛出「需要飞完」的航段,丢掉禁飞区内的段与碎屑。
const freeSegments = splitLine.features
.map(item => item.geometry.coordinates)
.filter(coords =>
coords.length > 1 &&
getPathLength(coords) > MIN_SEGMENT_METERS && // 实长 > 1 m,滤掉碎屑
!isInsideNfz(coords, obstacles) // 弧长中点不在禁飞区内
)
筛选的第三个条件是精髓:判定一段航段是否在禁飞区内,用的既不是端点也不是几何中心,而是「弧长中点」——沿航段走一半路程所在的位置。
为什么?lineSplit 的切点必然落在禁飞区边界上(它是航线与边界的交点)。如果用端点判定 booleanPointInPolygon,贴着边界外侧的航段端点恰好压线,会被误判成「区内段」而丢弃;而弧长中点必然远离两端的切点——一段穿越禁飞区的线,中点深入腹地;一段贴边或区外的线,中点稳定在安全侧。一个中点,把「穿过」和「贴过」干净利落地分开。
3.4 贪心重串:连接代价与「侧数量级」的降维
这是算法的调度核心,三个函数分工协作:linkFreeSegments 负责整体调度,estimateConnectionCost 负责排序,buildSafePath 负责实际连接。
调度:每一轮选代价最小的一组接入。
function linkFreeSegments(freeSegments, startPoint, obstacles) {
const turf = window.turf
const pending = freeSegments.slice()
const result = [startPoint]
let current = startPoint
while (pending.length) {
let bestIndex = 0
let bestReverse = false
let bestCost = Infinity
// 遍历「剩余航段 × 正反接入」组合,寻找连接代价最小者
pending.forEach((coords, index) => {
const entries = [
{ point: coords[0], reverse: false }, // 顺向接入
{ point: coords[coords.length - 1], reverse: true }, // 掉头接入
]
entries.forEach(({ point, reverse }) => {
// 剪枝:代价 ≥ 直线距离,直线距离已不小于当前最优时无需完整估算
if (turf.distance(current, point, { units: 'meters' }) >= bestCost) {
return
}
const cost = estimateConnectionCost(current, point, obstacles)
if (cost < bestCost) {
bestCost = cost; bestIndex = index; bestReverse = reverse
}
})
})
// 取出最优航段(bestReverse 时整体掉头),接入结果航线
const segment = bestReverse ? pending[bestIndex].slice().reverse() : pending[bestIndex]
pending.splice(bestIndex, 1)
appendCoordinates(result, buildSafePath(current, segment[0], obstacles).slice(1))
appendCoordinates(result, segment.slice(1))
current = segment[segment.length - 1]
}
return result
}
每个剩余航段有两种接入方向(首端进或尾端进),与当前航线终点配对计算代价,取全局最小——这是教科书式的贪心。它的剪枝依赖代价函数的一条关键性质:代价恒不小于直线距离。于是「直线距离 ≥ 当前最优」的组合可以跳过完整估算,大规模场景下剪枝率相当可观。
排序:代价 = 直线距离 + 被穿越障碍的短侧边界弧长。
function estimateConnectionCost(from, to, obstacles) {
const turf = window.turf
const straight = turf.distance(from, to, { units: 'meters' })
if (straight < SAME_POINT_METERS) return straight
let cost = straight
obstacles.forEach(item => {
if (!isPathBlocked(from, to, item)) return // 未被穿越的障碍不产生代价
const dFrom = turf.nearestPointOnLine(item.ring, turf.point(from), { units: 'meters' }).properties.location
const dTo = turf.nearestPointOnLine(item.ring, turf.point(to), { units: 'meters' }).properties.location
const forward = Math.abs(dTo - dFrom)
cost += Math.min(forward, item.ringLength - forward) // 短侧弧长
})
return cost
}
nearestPointOnLine 的 properties.location 是投影点沿线的累计弧长(单位随 units 选项,这里传 meters)——把 from 与 to 都投影到障碍的绕行环上,两个弧长位置把闭合环切成两段弧,取短者就是绕过这个障碍的理论最小额外路程。这段估算只用于排序,不生成路径,所以环上的顶点密度不影响它的速度。
为什么贪心在这里够用:同侧航段之间的连接代价只有一个航线间距(几十米),跨侧连接则要加上半圈边界弧长(几百米)——量级差决定了贪心每一步都自然优先衔接同侧航段,直到一侧扫完才付出一次绕行的代价。这正是「绕行次数由航段数量级降为侧数量级」的来源:不是靠精确求解,而是靠代价结构本身引导贪心走向正确布局。
连接:直线可通走直线,穿越障碍沿环走短侧弧。
function buildSafePath(from, to, obstacles, skipObstacles = []) {
const turf = window.turf
if (turf.distance(from, to, { units: 'meters' }) < SAME_POINT_METERS) {
return [from, to] // 首尾几乎重合,无需绕行
}
const blocking = obstacles.find(item =>
!skipObstacles.includes(item) && isPathBlocked(from, to, item))
if (!blocking) return [from, to] // 直线可通
const arc = getBoundaryArc(blocking, from, to)
if (!arc) return [from, to] // 无可用弧(退化情况),残留交给兜底
const nextSkip = [...skipObstacles, blocking]
const head = buildSafePath(from, arc[0], obstacles, nextSkip)
const tail = buildSafePath(arc[arc.length - 1], to, obstacles, nextSkip)
// 三段拼接:去掉 head 尾点与 tail 首点(分别与弧的首尾点重合)
return [...head.slice(0, -1), ...arc, ...tail.slice(1)]
}
递归的动因:绕行弧的端点(落在绕行环上)可能又贴近另一个障碍物——from → 弧起点 与 弧终点 → to 两段分别递归求解,直到两段都无阻挡。skipObstacles 记录本条连接已展开过的障碍,保证每个障碍至多展开一次:既防住「弧端点又落回同一障碍」的无限递归,也封顶了最坏情况的展开规模。代价是极端多障碍嵌套时个别连线可能残留穿越(该障碍已被跳过且弧不可用)——这是刻意的选择,残局交给第 ⑥ 步兜底,比在递归里追求完美便宜得多。
绕行弧怎么生成(getBoundaryArc)核心是弧长投影的加减法:
const dFrom = turf.nearestPointOnLine(ring, turf.point(from), { units: 'meters' }).properties.location
const dTo = turf.nearestPointOnLine(ring, turf.point(to), { units: 'meters' }).properties.location
const d1 = Math.min(dFrom, dTo)
const d2 = Math.max(dFrom, dTo)
// 正向弧 d1 → d2;反向弧 d2 → 环长,绕过起点闭合处回到 d1
const forward = turf.lineSliceAlong(ring, d1, d2, { units: 'meters' }).geometry.coordinates
const backward = [
...turf.lineSliceAlong(ring, d2, ringLength, { units: 'meters' }).geometry.coordinates,
...turf.lineSliceAlong(ring, 0, d1, { units: 'meters' }).geometry.coordinates
]
const arc = getPathLength(forward) <= getPathLength(backward) ? forward : backward
闭合环上两投影点切出正反两段弧,比较实长取短者——注意反向弧要穿过环的起点闭合处(d2 → ringLength 接 0 → d1 两段拼接),漏拼闭合处是弧长计算的常见 off-by-one。最后统一弧的方向(起点靠近 from),保证三段拼接时首尾相接的顺序正确。
3.5 距离抽稀:基准是上一个保留点
角色:绕行环与边界弧带来大量密集顶点(buffer 的圆角细分),超出飞控航点数限制,需要抽稀。
function simplifyCoordinates(coordinates, threshold, keepLast) {
const turf = window.turf
const simplified = [coordinates[0]] // 始终保留第一个点
for (let i = 1; i < coordinates.length; i++) {
// 与上一个已保留点比较距离,而非与上一个原始点比较
const distance = turf.distance(
simplified[simplified.length - 1], coordinates[i],
{ units: 'meters' }
)
if (distance > threshold) simplified.push(coordinates[i])
}
if (keepLast && coordinates.length > 1 &&
simplified[simplified.length - 1] !== coordinates[coordinates.length - 1]) {
simplified.push(coordinates[coordinates.length - 1]) // 强制补回末点
}
return simplified
}
两个细节:基准点用的是上一个已保留点而非上一个原始点——这保证了任意两个保留点之间的实际距离必然大于阈值(若与原始点比,连续密集点会一路漏过阈值);keepLast 强制补回原始末点,航线末端缺口会让闭合航段断链。threshold 取 10 m(SIMPLIFY_METERS),与飞控「航点数 ≤ 999」的常见限制在百公里尺度内都安全。
3.6 兜底去交:递归展开的残局处理
角色:最后防线。逐段检查相邻航点的连线与任何禁飞区的相交性,相交则丢弃当前点——等于把穿越禁飞区的连线整段断开。
const strictNfz = turf.buffer(nfzfeatureCollection, -STRICT_SHRINK, { units: 'meters' })
// ……
function removeIntersectingPoints(coordinates, nfz) {
const turf = window.turf
const result = [coordinates[0]] // 始终保留第一个点
for (let i = 1; i < coordinates.length; i++) {
const lineSegment = turf.lineString([result[result.length - 1], coordinates[i]])
let hasIntersection = false
for (const feature of nfz.features) {
if (turf.lineIntersect(lineSegment, feature).features.length > 0) {
hasIntersection = true
break
}
}
if (!hasIntersection) result.push(coordinates[i])
}
return result
}
它同时兑现两类场景:主流程早退(分割抛错 / 无有效航段)时直接清洗原始航线,输出保守的「不穿越」航线;正常流程收尾时剔除 3.4 节递归展开残留的穿越连线。判定面用的是整体再内缩 5 m 的 strictNfz——与障碍判定面同源的贴边豁免思路,为最终输出再留一层安全余量。
四、完整实现
以下即 src/lib/nfzDetour.js 全文(与文末演示组件所用完全一致),相比草稿版唯一的改动是把全局 nfzCollection 参数化为 getAutoWayline 的入参——纯函数化的签名让它可以被任意场景复用与测试。函数注释的编号对应上文流程图:
// —— 算法参数(单位均为米,经 turf 以 { units: 'meters' } 计算)——
const EDGE_TOLERANCE = 0.5 // 贴边容差:障碍判定面内缩 / 绕行环外扩的距离
const STRICT_SHRINK = 5 // 兜底判定面整体再内缩的距离,为贴边飞行留安全余量
const MIN_SEGMENT_METERS = 1 // 航段实长低于该值视为分割碎屑,直接丢弃
const SAME_POINT_METERS = 0.1 // 「同一点」判定阈值(投影重合 / 首尾重合)
const DUP_POINT_METERS = 0.05 // 坐标去重阈值:与已保留末点近于此值视为重复点
const SIMPLIFY_METERS = 10 // 抽稀阈值:与上一保留点距离小于该值的点被丢弃
/**
* 自动绕行禁飞区主入口(编排层)。
* @param {[number, number][]} waylinePoints 已规划好的航线航点([lng, lat])数组
* @param {turf.Feature[]} nfzCollection 禁飞区面要素数组(建议已含安全缓冲)
* @returns {[number, number][]} 绕行后的航点数组;无禁飞区时原样返回
*/
export function getAutoWayline(waylinePoints, nfzCollection) {
if (!nfzCollection || !nfzCollection.length) {
return waylinePoints
}
const turf = window.turf
const waylineLineString = turf.lineString(waylinePoints)
const nfzfeatureCollection = turf.featureCollection(nfzCollection)
// 兜底判定面:整体内缩 STRICT_SHRINK,最终去交时为贴边飞行留出余量
const strictNfz = turf.buffer(nfzfeatureCollection, -STRICT_SHRINK, { units: 'meters' })
// 兜底结果:绕行不可行时,退化为剔除与禁飞区相交连线的保守航线
const fallback = () => removeIntersectingPoints(waylinePoints, strictNfz)
const obstacles = buildObstacles(nfzCollection)
// ② 航线分割:多禁飞区先 union 成整体,避免相邻禁飞区间出现无法绕行的缝隙
let splitLine
try {
const splitter = nfzfeatureCollection.features.length > 1
? turf.union(nfzfeatureCollection)
: nfzfeatureCollection.features[0]
splitLine = turf.lineSplit(waylineLineString, splitter)
} catch (e) {
return fallback() // union / lineSplit 遇到退化几何可能抛错,走兜底
}
if (splitLine.features.length === 0) {
return fallback() // 航线未触及禁飞区,或被禁飞区完全覆盖
}
// ③ 有效航段筛选:丢弃禁飞区内的航段与分割碎屑,只保留需要飞完的航段
const freeSegments = splitLine.features
.map(item => item.geometry.coordinates)
.filter(coords =>
coords.length > 1 &&
getPathLength(coords) > MIN_SEGMENT_METERS &&
!isInsideNfz(coords, obstacles))
if (freeSegments.length === 0) {
return fallback()
}
// ④ 贪心重串 → ⑤ 抽稀(保留末点)→ ⑥ 兜底去交(剔除残留穿越线)
return removeIntersectingPoints(
simplifyCoordinates(
linkFreeSegments(freeSegments, waylinePoints[0], obstacles),
SIMPLIFY_METERS, true),
strictNfz
)
}
/** ① 障碍物预处理:每个禁飞区外环派生判定面(内缩 EDGE_TOLERANCE)、
* 绕行环(外扩 EDGE_TOLERANCE)与环长;buffer 退化返回 null 时退回原始外环。 */
function buildObstacles(nfzCollection) {
const turf = window.turf
const obstacles = []
nfzCollection.forEach(feature => {
turf.flatten(feature).features.forEach(item => {
const outer = turf.polygon([item.geometry.coordinates[0]]) // 只取外环
const blockPolygon = turf.buffer(outer, -EDGE_TOLERANCE, { units: 'meters', steps: 2 })
const walkPolygon = turf.buffer(outer, EDGE_TOLERANCE, { units: 'meters', steps: 2 })
const ring = turf.lineString(
(walkPolygon?.geometry?.coordinates?.length ? walkPolygon : outer).geometry.coordinates[0])
obstacles.push({
polygon: blockPolygon?.geometry?.coordinates?.length ? blockPolygon : outer,
ring,
ringLength: turf.length(ring, { units: 'meters' })
})
})
})
return obstacles
}
/** 折线实长(米);空值 / 少于 2 点返回 0 */
function getPathLength(coords) {
if (!coords || coords.length < 2) return 0
return window.turf.length(window.turf.lineString(coords), { units: 'meters' })
}
/** ③ 筛选条件:取航段弧长中点判定(lineSplit 切点落在边界上,端点判定会误杀贴边段) */
function isInsideNfz(coords, obstacles) {
const turf = window.turf
const midPoint = turf.along(turf.lineString(coords), getPathLength(coords) / 2, { units: 'meters' })
return obstacles.some(item => turf.booleanPointInPolygon(midPoint, item.polygon))
}
/** from → to 直线是否穿越障碍(判定面内缩 EDGE_TOLERANCE,贴边不算穿越) */
function isPathBlocked(from, to, obstacle) {
return window.turf.booleanIntersects(window.turf.lineString([from, to]), obstacle.polygon)
}
/** ④ 边界绕行弧:两投影点弧长位置切出正反两弧取短者,方向统一为起点靠近 from */
function getBoundaryArc(obstacle, from, to) {
const turf = window.turf
const { ring, ringLength } = obstacle
if (!ringLength) return null
const dFrom = turf.nearestPointOnLine(ring, turf.point(from), { units: 'meters' }).properties.location
const dTo = turf.nearestPointOnLine(ring, turf.point(to), { units: 'meters' }).properties.location
const d1 = Math.min(dFrom, dTo)
const d2 = Math.max(dFrom, dTo)
if (d2 - d1 < SAME_POINT_METERS) return null // 投影重合:直线擦过一点,无需绕行
const forward = turf.lineSliceAlong(ring, d1, d2, { units: 'meters' }).geometry.coordinates
const backward = [
...turf.lineSliceAlong(ring, d2, ringLength, { units: 'meters' }).geometry.coordinates,
...turf.lineSliceAlong(ring, 0, d1, { units: 'meters' }).geometry.coordinates
]
const arc = getPathLength(forward) <= getPathLength(backward) ? forward : backward
if (arc.length < 2) return null
const headToFrom = turf.distance(arc[0], from, { units: 'meters' })
const tailToFrom = turf.distance(arc[arc.length - 1], from, { units: 'meters' })
return headToFrom <= tailToFrom ? arc : arc.slice().reverse()
}
/** ④ 实际连接:直线可通走直线;穿越障碍沿绕行环走短侧弧,弧端点附近
* 再遇其他障碍则递归处理;skipObstacles 保证每个障碍至多展开一次(防无限递归)。 */
function buildSafePath(from, to, obstacles, skipObstacles = []) {
const turf = window.turf
if (turf.distance(from, to, { units: 'meters' }) < SAME_POINT_METERS) return [from, to]
const blocking = obstacles.find(item =>
!skipObstacles.includes(item) && isPathBlocked(from, to, item))
if (!blocking) return [from, to]
const arc = getBoundaryArc(blocking, from, to)
if (!arc) return [from, to] // 无可用弧:直线连接,残留交给兜底去交
const nextSkip = [...skipObstacles, blocking]
const head = buildSafePath(from, arc[0], obstacles, nextSkip)
const tail = buildSafePath(arc[arc.length - 1], to, obstacles, nextSkip)
return [...head.slice(0, -1), ...arc, ...tail.slice(1)]
}
/** ④ 连接代价(仅排序):直线距离 + 每个被穿越障碍的短侧弧长,恒 ≥ 直线距离 */
function estimateConnectionCost(from, to, obstacles) {
const turf = window.turf
const straight = turf.distance(from, to, { units: 'meters' })
if (straight < SAME_POINT_METERS) return straight
let cost = straight
obstacles.forEach(item => {
if (!isPathBlocked(from, to, item)) return
const dFrom = turf.nearestPointOnLine(item.ring, turf.point(from), { units: 'meters' }).properties.location
const dTo = turf.nearestPointOnLine(item.ring, turf.point(to), { units: 'meters' }).properties.location
const forward = Math.abs(dTo - dFrom)
cost += Math.min(forward, item.ringLength - forward)
})
return cost
}
/** 追加坐标并去重:与目标末点距离 ≤ DUP_POINT_METERS 的点丢弃 */
function appendCoordinates(target, coords) {
const turf = window.turf
coords.forEach(coord => {
const last = target[target.length - 1]
if (!last || turf.distance(last, coord, { units: 'meters' }) > DUP_POINT_METERS) {
target.push(coord)
}
})
}
/** ④ 调度核心:从起飞点出发,每轮在「剩余航段 × 首尾接入」中选连接代价
* 最小者接入,直至耗尽。贪心不保证全局最优,但代价结构让同侧航段天然先衔接。 */
function linkFreeSegments(freeSegments, startPoint, obstacles) {
const turf = window.turf
const pending = freeSegments.slice()
const result = [startPoint]
let current = startPoint
while (pending.length) {
let bestIndex = 0
let bestReverse = false
let bestCost = Infinity
pending.forEach((coords, index) => {
const entries = [
{ point: coords[0], reverse: false },
{ point: coords[coords.length - 1], reverse: true }]
entries.forEach(({ point, reverse }) => {
// 剪枝:代价 ≥ 直线距离,直线距离已不小于当前最优时无需完整估算
if (turf.distance(current, point, { units: 'meters' }) >= bestCost) return
const cost = estimateConnectionCost(current, point, obstacles)
if (cost < bestCost) {
bestCost = cost; bestIndex = index; bestReverse = reverse
}
})
})
const segment = bestReverse ? pending[bestIndex].slice().reverse() : pending[bestIndex]
pending.splice(bestIndex, 1)
appendCoordinates(result, buildSafePath(current, segment[0], obstacles).slice(1))
appendCoordinates(result, segment.slice(1))
current = segment[segment.length - 1]
}
return result
}
/** ⑤ 距离抽稀:与上一个保留点距离 > threshold 才保留;keepLast 强制保留末点 */
function simplifyCoordinates(coordinates, threshold, keepLast) {
const turf = window.turf
const simplified = [coordinates[0]]
for (let i = 1; i < coordinates.length; i++) {
const distance = turf.distance(
simplified[simplified.length - 1], coordinates[i], { units: 'meters' })
if (distance > threshold) simplified.push(coordinates[i])
}
if (keepLast && coordinates.length > 1 &&
simplified[simplified.length - 1] !== coordinates[coordinates.length - 1]) {
simplified.push(coordinates[coordinates.length - 1])
}
return simplified
}
/** ⑥ 兜底去交:相邻保留点连线与任一禁飞区相交则丢弃当前点(断开穿越连线) */
function removeIntersectingPoints(coordinates, nfz) {
const turf = window.turf
if (!coordinates || coordinates.length < 2) return coordinates
const result = [coordinates[0]]
for (let i = 1; i < coordinates.length; i++) {
const lineSegment = turf.lineString([result[result.length - 1], coordinates[i]])
let hasIntersection = false
for (const feature of nfz.features) {
if (turf.lineIntersect(lineSegment, feature).features.length > 0) {
hasIntersection = true
break
}
}
if (!hasIntersection) result.push(coordinates[i])
}
return result
}
五、Cesium 集成:双航线对比演示
算法是纯函数,getAutoWayline(waylinePoints, nfzCollection) 进、绕行航点出。把它变成可交互的对比演示,工程重心依旧在 Cesium 侧——组件 src/components/cesium/NfzDetourDemo.vue 的关键决策如下(引擎加载沿用已收敛到 src/composables/useCesiumEngine.ts 的单例 Hook,不再赘述)。
5.1 一次点击,两条航线
「生成航线」按钮触发一次完整链路:扫描线算法(与上一篇共用 src/lib/scanRoute.js 的同一实现)生成原始航线,绕行算法处理禁飞区,两条航线的坐标分别写入两个模块级可变数组,CallbackProperty 每帧读取、原地更新免删建:
function recomputeRoutes() {
const { waypoints } = calculatePolygonalRoute(DEFAULT_SURVEY_AREA, params.spacing, params.direction)
const detoured = getAutoWayline(waypoints, nfzFeatures)
// 原地替换数组内容 —— 蓝虚线(原始)与绿实线(绕行)的实体自动重绘
replaceArray(origCartesians, waypoints.map((p) => toCartesian(p, ROUTE_ALT_M)))
replaceArray(detourCartesians, detoured.map((p) => toCartesian(p, ROUTE_ALT_M)))
// 统计:航点数 / 航程 / 穿越次数(绕行后应为 0)
stats.origPoints = waypoints.length
stats.origCrossings = countCrossings(waypoints)
stats.detourPoints = detoured.length
stats.detourLength = lengthOf(detoured)
stats.crossings = countCrossings(detoured)
}
/** 相邻航点连线与任一禁飞区相交的段数 */
function countCrossings(points) {
let count = 0
for (let i = 1; i < points.length; i++) {
const segment = window.turf.lineString([points[i - 1], points[i]])
if (nfzFeatures.some((f) => window.turf.lineIntersect(segment, f).features.length > 0)) count += 1
}
return count
}
countCrossings 是演示的「裁判」:原始航线通常十几处穿越,绕行航线恒为 0——统计面板把这两个数字并排摆在读者眼前,算法的必要性一目了然。
5.2 禁飞区:buffer 环缓存 + 每环一对实体
禁飞区渲染的是缓冲后的边界(算法真正避让的几何),而可拖拽的编辑点是原始顶点——两层几何分离,buffer 结果只在重算时生成一次并缓存,CallbackProperty 只读缓存数组,避免每帧重算 buffer:
function updateNfzRendering() {
nfzFeatures = [] // 绕行算法与穿越统计的输入
nfzBufferRings.length = 0 // 渲染环缓存
nfzPolygons.forEach((poly) => {
if (poly.length < 3) return
const feature = window.turf.buffer(
window.turf.polygon([closeRing(poly)]), NFZ_BUFFER_M, { units: 'meters', steps: 5 })
if (feature && feature.geometry.coordinates.length) {
nfzFeatures.push(feature)
nfzBufferRings.push(feature.geometry.coordinates[0])
}
})
syncNfzEntities() // 填充 / 虚线边框实体数量与环数量对齐(每环一对)
}
拖拽顶点沿用了上一篇的模式:ScreenSpaceEventHandler 按下时 scene.pick 拾取顶点实体(id 以 nfz-vtx- 前缀编码多边形与顶点序号)、冻结相机控制器,移动即更新 reactive 顶点坐标并触发 requestAnimationFrame 节流的重算——拖动红色顶点,两条航线与统计像上一篇的滑块一样即时跟随。
5.3 GeoJSON 上传:递归提取外环
上传入口接受任意形态的 GeoJSON(FeatureCollection / Feature / 裸 geometry),递归取出全部 Polygon 外环与 MultiPolygon 子面——与算法 buildObstacles 的保守策略同构,内环一律忽略:
function extractPolygons(geojson) {
const polys = []
const walkGeometry = (geom) => {
if (!geom) return
if (geom.type === 'Polygon') polys.push(geom.coordinates[0])
else if (geom.type === 'MultiPolygon') geom.coordinates.forEach((c) => polys.push(c[0]))
}
const walkObject = (obj) => {
if (!obj || typeof obj !== 'object') return
if (obj.type === 'FeatureCollection') obj.features.forEach(walkObject)
else if (obj.type === 'Feature') walkGeometry(obj.geometry)
else walkGeometry(obj)
}
walkObject(geojson)
return polys
.map((ring) => {
// 去 GeoJSON 闭合点(首尾相同则去掉末点)
const first = ring[0]
const last = ring[ring.length - 1]
return first[0] === last[0] && first[1] === last[1] ? ring.slice(0, -1) : ring
})
.filter((ring) => ring.length >= 3 && ring.every((c) => Number.isFinite(c[0]) && Number.isFinite(c[1])))
}
上传成功即替换当前禁飞区并重算航线;解析失败只清空 input.value 并提示错误——允许重复上传同一文件是个容易漏的细节(不重置 value 的话,第二次选同名文件不触发 change 事件)。
完整组件代码在仓库 src/components/cesium/NfzDetourDemo.vue(含天地图底图、起点标记、图例与统计面板),结构与上一篇的 FlightRoutePlanner.vue 同构,可直接复制改造。
六、总结与展望
把这条链路串起来:禁飞区多边形(数据层)→ 两级缓冲障碍物(几何层)→ 分割与筛选(切分层)→ 贪心重串 + 边界绕行(调度层)→ 抽稀与兜底去交(清洗层)。除了贪心与递归两处刻意保守的取舍,每一层都是纯函数,任何一层的输入变化都能在下一帧反映到地图上。
它的优势用三个数字概括:绕行次数只随侧数增长、输出保证零穿越(兜底兜底再兜底)、贴边飞行有明确的安全容差。它的局限也必须摊开说:贪心不保证全局最优(同侧内部次序可能次优);递归展开在多障碍嵌套时可能残留穿越连线,靠第 ⑥ 步断链兜底(航线可能因此出现跳跃);容差参数(0.5 m / 5 m / 20 m)是静态经验值,极端坐标精度场景需要标定。
往后还能走的路:多架次任务分组(绕行后航程超限自动拆架次,起飞点参与贪心排序);电量约束下的航线切分(以连接代价为割点做图划分);地形跟随(绕行环的三维化——山体障碍物是体而不是面);转弯平滑(绕行弧与航段衔接处 Dubins 曲线过渡)。以及一件工程上更紧迫的事:把「贪心 + 递归」换成带记忆化的近似最优解,让六段以上的复杂布局也能拿到理论下界。
下面是活的演示台。生成航线后拖动禁飞区的红色顶点、上传自己的 GeoJSON 或调整扫描参数——蓝虚线与绿实线的每一次重排,都是贪心调度在实时落子:
Live Demo
航线绕行禁飞区 · 原始 vs 绕行对比
正在从 CDN 加载 Cesium 与 Turf.js…
原始扫描线航线绕行航线绕行航点禁飞区(含 20 m 缓冲,顶点可拖拽)
点击「生成航线」:先以扫描线算法铺出原始航线(蓝虚线),再执行绕行算法输出绿色实线与航点。 拖动红色顶点改变禁飞区形状、或上传自己的 GeoJSON(Polygon / MultiPolygon,WGS84),两条航线与统计即时重算—— 绕行列的穿越数应始终为 0。底图:天地图卫星影像与注记。
(约 6 MB 的 Cesium 与 547 KB 的 Turf.js 在演示区滚入视口时才从 CDN 加载;默认场景即上文数据——两处禁飞区、六条扫描线,绕行前后对比一屏可见。)
曲则全,枉则直。航线遇礁而绕,非退也,行远之计也。落笔为记,以飨同好。