第55篇:空间分析-剖面分析——红线走大圆,剖面却按经纬直线采样 上一篇结尾我撂下一句话这条空间分析线才刚开头。地形挖开了下一步自然就是沿着一条线把它切开看看里面长什么样——剖面分析我们下一篇见。坑挖完了现在该切了。从 #53 的标记标绘到 #54 的地形开挖这条空间分析线走的其实是同一个逻辑**先把球上看得见的东西变成算得准的东西。**点位标记那篇我们把一个点变成了清单里的一行地形开挖那篇我们把一块地变成了一个洞。今天轮到一条线——把球上那条剖面线变成一张高程曲线图。剖面分析是空间分析里最不花哨、但最常被要求交付的功能。选线、定管线、算输电通道、评估徒步路线最后交出去的东西往往就是一张剖面图横轴距离、纵轴高程一眼看出一条线是爬升还是下降、哪里最陡、哪里有个垭口。它是那种做对了没人夸、画歪了立刻被指着鼻子问的功能。但这条线比看起来难搞。球上那条红线有它自己走的路径剖面图有它自己采的路径中间还夹着一个类别轴——本猿一开始以为这三件事是一回事量完之后发现是三回事。大家好我是 Cesium 酱也可以叫我本猿一名在 WebGIS 领域摸爬滚打多年的前端开发者。写这篇的时候是凌晨我把 demo 里的默认路径来回画了十几遍就为了确认一个反直觉的现象球上那条红线中间明明没拐弯剖面曲线却在中间鼓了个包。后来发现那不是我画歪了——是红线和剖面采的根本不是同一条线。本文目标把剖面分析这条链路切成四段——密化路径、挑采样点、取高程、画曲线然后把每一段的误差都量出来剖面曲线上的每个点到底在哪儿、横轴上那个距离是不是真的距离、那条 10° 的斜线上两条线能差出多远。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。一、痛点与场景为什么切条线看剖面这么容易翻车 背景现状剖面分析干的事很朴素——沿着一条线每隔一段取一次地面高程再把距离—高程画成二维曲线。它是把一个三维问题压成二维而压的过程里每一步都在丢信息路径被简化成折点、折点之间被插成采样点、采样点被取成高程、高程被画进一个横轴等距的画布里。四步走下来图是圆的数据是尖的差在哪儿全靠底层实现。典型场景里它要同时满足这些需求️选线一条拟建公路 / 管廊 / 输电线路要先知道它翻几道梁、最深挖多少管线纵断面市政管线要按坡度走剖面图是唯一的校核依据徒步 / 越野路线评估累计爬升、最大坡度、补给点落差填挖方粗算沿线的地面线与设计线一对比粗略知道哪段是挖、哪段是填科研 / 教学地形起伏的可视化表达丘陵、河谷、阶地一眼可见 传统痛点本猿把常见的几种做法列了一下每一条都踩过方案问题桌面 GIS 切剖面再导出图表数据要出浏览器、出进程、出软件来回三趟改一次参数重跑一遍直接sampleTerrain猛打点拿到的是一串高程但它不知道这条线的哪一段最陡——因为没算累积距离采完样随手用 Excel 连线横轴按第几个点排不是按距离排点密的地方被压扁、点疏的地方被撑开拿球上那条红线当基准那条线走的是测地线大圆而采样走的是经纬度线性插值长线上根本不在一条沟里用globe.getHeight取高程它返回undefined的时候你未必看得见——demo 里一句?? 0就把它变成海平面了 本文方案这个案例的实现链路其实清清楚楚就四段1️⃣密化路径把用户给的折点或内置的演示路径按每段固定 500 个插值点铺成一条密集折线同时给每个点算一个累积距离2️⃣挑采样点按每段步数 / 固定间距 / 总点数三种方式从密化折线上挑出真正要取高程的位置3️⃣取高程对每个采样点调viewer.scene.globe.getHeight()拿回三角网格上的高度4️⃣画曲线把累积距离高程丢给 ECharts画一条带面积填充的曲线✨ 一句话总结把一条剖面线当成一条带累积距离的密集折线用地形三角网格的射线求交当高程源最后把结果按采样序号等距摆进一张二维图里——每一步都对可每一步都在丢东西。问题不在于哪一步算错了在于没人告诉你丢的是什么。二、核心技术原理一条剖面四段拼出来2.1 路径与采样的数据结构整个案例前后跑动的其实只有两个结构一个比一个大// 密化折线上的一个点位置 累积距离typeSamplePoint{lon:number;lat:number;cumDistM:number}// demo 里路径本身只用 [lon, lat] 两个数表示constDEFAULT_PATH:[number,number][][[90.5,30.5],[91,31],[91.5,31.5],[90.5,31.5]]// 剖面数组里每一行的含义经度 / 纬度 / 高程 / 累积距离typeProfilePoint[number,number,number,number]// 源码级剧透buildDenseSamples里那个DENSE_PER_SEGMENT 500是每段插 500 个点不是整条线插 500 个点。所以默认 3 段路径的密化数组长度是1 500 × 3 1501而你手绘一条 20 个折点的路径它会涨到1 500 × 19 9501。这个 500 是写死的跟段长一点关系都没有一段 100 米的线和一段 1500 公里的线都插 500 个点。这笔账第五节还要再算一次。2.2 完整技术路线我们设计的完整技术路线核心亮点第 ④ 步取高程是本文的杀手锏 ——它的返回值只有三种可能一个数字、undefined、或者抛异常。而 demo 里只接住了第一种。2.3 三种采样方式的差别面板上那个下拉框看着只是参数习惯不同实际决定的是横轴上一格代表多少米。三种方式的算法源码里就是一个if / else if / else采样方式原理默认路径点数一格代表每段步数默认每段独立按stepCount等分stepCount20~3003×1001−2 301每段不同733.080 / 738.842 / 957.667 m固定间距n floor(总长 / 间距)从 0 取到n×间距5000 m 档 →49点恒定5000 m总采样点数n 总点数直接等分全长20~1000恒定总长 ÷ (n−1)前两种的差别很微妙但很要命每段步数每段都给同样多的点所以段越长、每格越大。这条路径的段 3 就是活证据。固定间距每格一样宽但它是从起点往后数整格最后一格走不满就被丢掉——totalLength报的是n × 间距不是真实全长。 一句必须记住的话**固定间距下报告的总长永远 ≤ 真实总长缺口最大是一个间距。**默认路径实测100 m 档丢 62.471 m2000 m 档丢1262.471 m5000 m 档丢 1262.471 m。而总采样点数和每段步数两个模式不截断最后一点就压在终点上。三、代码实战三个关键实现⚠️温馨提示下面只贴关键片段完整的单文件组件在仓库src/cases/profile-analysis/ProfileAnalysisDemo.vue。这个 demo 依赖 Bing 影像 Cesium World Terrain把 Bing 换成自己的底图就能跑。3.1 密化 采样两个数组一次去重constDENSE_PER_SEGMENT500// 距离用球面大圆公式半径取的是 IUGG 平均半径不是 WGS84functionhaversineMeters(lon1,lat1,lon2,lat2){constR6371008.8constdLatCesiumMath.toRadians(lat2-lat1)constdLonCesiumMath.toRadians(lon2-lon1)constaMath.sin(dLat/2)**2Math.cos(CesiumMath.toRadians(lat1))*Math.cos(CesiumMath.toRadians(lat2))*Math.sin(dLon/2)**2return2*R*Math.asin(Math.sqrt(a))}// 密化的核心位置各自线性插值距离按段长等比分配for(leti1;iDENSE_PER_SEGMENT;i){constti/DENSE_PER_SEGMENTdense.push({lon:CesiumMath.lerp(sx,ex,t),lat:CesiumMath.lerp(sy,ey,t),cumDistM:cumsegLen*t})}// 每段步数模式下第 i 段的终点和第 i1 段的起点是同一个累积距离for(leti0;istepCount;i)targets.push(cum(segLen*i)/stepCount)targetstargets.filter((v,i)i0||v!targets[i-1]) 关键点位置和距离用的是两套算法。位置是lon / lat各自线性插值距离是起点到终点的 haversine 距离乘以t。两者在短段上贴合得很好段一长就分家。每段步数模式下targets天然有重复靠上面那句filter去重。本猿把stepCount从 20 到 300 逐个试了一遍重复对恒等于段数 − 1去重是真的生效的——因为segLen × stepCount / stepCount在这个量级上恰好浮点相等。但重复的折点去不掉。如果你在地图上手抖把同一个位置点了两次那一整段的targets全部相等去重后只剩 1 个——少采一整段。实测 4 个折点里首两重合、stepCount 100时采样点从 301 掉到201。3.2 取高程一个?? 0掩盖了三种情况for(constsampleofsamples){letheight0try{constheightValueviewer.scene.globe.getHeight(Cartographic.fromDegrees(sample.lon,sample.lat))heightheightValue??0// 未加载 / 出界 / 没渲染 → 一律 0 m}catch{height0// 连异常也吃掉}profile.push([sample.lon,sample.lat,height,sample.cumDistM])} 关键点getHeight的返回值只有三种demo 只接住了一种。Cesium 未压缩源码里Globe.prototype.getHeight有三处return undefined零级瓦片还没建好L866、经纬度不在任何零级瓦片里L874、以及往下找到的最深那层瓦片没有renderedMeshL912。它是逐级往下钻的从零级瓦片沿四叉树一路往下只把有renderedMesh的瓦片记成tileWithMesh最后在这块瓦片上取交点。也就是说——剖面精度取决于当前渲染到哪一层。你把相机拉近、瓦片加载得更细同一条剖面线的高程会变。高程来自三角面不是高程栅格。链路是GlobeSurfaceTile.pick→TerrainMesh.pick→terrainPicker.rayIntersect本质是沿椭球面法线打一条射线、和三角网格求交落在三角形内部就是线性插值。所以采样点比网格密的那部分是白采的。3.3 画曲线三个看起来无害的选项chart.setOption({grid:{left:40,right:16,top:24,bottom:28},xAxis:{type:category,// 类别轴等距与距离数值无关boundaryGap:false,name:距离 (km),data:distances,// 距离字符串只当刻度标签用axisLabel:{showMinLabel:true,showMaxLabel:true,interval:Math.max(1,Math.floor(distances.length/6))}},yAxis:{type:value,name:高程 (m),scale:true},// scale 不从 0 起series:[{data:heights,type:line,smooth:true,showSymbol:false,lineStyle:{color:#2f80ed,width:2},areaStyle:{color:rgba(47, 128, 237, 0.35)}}]}) 关键点type: category把 301 个采样点摆成 301 个等宽刻度。distances数组里的 “0.00 / 41.01 / 82.03 …” 只是标签不参与定位。这一点是后面所有横轴失真的总源头。interval是跳过几个不是每几个显示一个。写interval: 50实测显示的是第 0、51、102、153、204、255 个刻度再加上showMaxLabel: true补的终点一共7 个标签。scale: true让 y 轴不从 0 起。默认数据范围 2760~4210 m实测轴范围被算成2700~4500 m——那块蓝色面积的基线是2700 m不是海平面。smooth: true不会制造假峰。本猿本来攒了一堆平滑把尖峰冲过头的素材实测发现 ECharts 把控制点钳制在了相邻两点的包围盒里源码注释写得很直白Avoid exceeding extreme after smoothing实测最大越界只有 0.74 m——那是像素取整的噪声172 px 对应 2200 m1 px 12.79 m。所以这一条不用避。四、功能亮点与性能优势4.1 三种采样方式的点数与代价本猿把三种方式在默认路径上都跑了一遍Node 里复刻了同一套算法cesium/engine26.2.0 ECharts 6.1.0不是估算采样方式档位采样点数密化数组报告总长与真实总长 241262.471 m 的缺口每段步数20611501241262.471 m0每段步数100默认3011501241262.471 m0每段步数3009011501241262.471 m0固定间距100 m24131501241200 m−62.471 m固定间距500 m4831501241000 m−262.471 m固定间距2000 m1211501240000 m−1262.471 m固定间距5000 m491501240000 m−1262.471 m总采样点数20201501241262.471 m0总采样点数100010001501241262.471 m0注意最后一列固定间距永远差那么一点而且差多少由间距决定不由路径决定——缺口精确等于真实总长 mod 间距。再看采样点数和绘制对象数的关系——折点数会让采样点数爆炸路径密化数组每段步数 20每段步数 100每段步数 300默认 4 折点150161301901手绘 20 折点9501381190157014.2 横坐标等距的画布不等距的现实这是本猿最想让大家看一眼的一张图实测三段的真实长度haversineR 6371008.873308.018 / 73145.390 / 94809.063 m占全长的 30.39 % / 30.32 % /39.30 %。而图上它们占的宽度是 33.33 % / 33.00 % /33.00 %。于是段真实长度真实占比图上占比每格米数失真系数段 173308.018 m30.39 %33.33 %733.080 m1.0970×段 273145.390 m30.32 %33.00 %738.842 m1.0885×段 394809.063 m39.30 %33.00 %957.667 m0.8398×最疏的一格比最密的一格宽1.3064 倍。段 3 比段 1 长21501.045 m在图上却一样宽——它的每一段爬升被横向压扁到 76.5 %看着比实际平缓 23.5 %。简单说**类别轴画出来的是采样序号的曲线不是距离的曲线。**采样均匀固定间距模式时两者一致采样不均匀时每段步数模式、或折点分布不均时图上的坡度就是假的。顺带说一句 y 轴。scale: true把轴范围定在 2700~4500 m面积图的基线落在这 2700 m 上——那块看着像山体截面的蓝色填充底座其实是悬空的。真要表达填挖方体积这类量scale必须关掉关掉后轴范围实测是 0~5000 m。4.3 那条红线到底走的是哪条线球上那条红线是polyline.clampToGround true没有指定arcType。查源码GroundPolylineGeometry的默认值是这么写的// cesium/engine · Source/Core/GroundPolylineGeometry.js// param {ArcType} [options.arcTypeArcType.GEODESIC]// param {number} [options.granularityCesiumMath.RADIANS_PER_DEGREE]this.arcTypeoptions.arcType??ArcType.GEODESICgranularity的默认值是 1°也就是111.195 km。实测PolylinePipeline.generateArc在不同跨度上输出的顶点数路径跨度generateArc 顶点数0.5°21°22°35°610°10默认路径三段全都不够 1°红线中间一个点都没插——它就是两个端点之间的一条直线。而剖面的采样点是在lon / lat上线性插值的画在球上是一条经纬线。两者中间差多少路径haversine 段长采样点偏离测地线最大距离默认段 10.5° 斜线73.308 km67.567 m默认段 31.0° 纬线94.809 km108.297 m手绘 5° 纬线476.524 km2684.266 m手绘 10° 纬线952.806 km10754.602 m手绘 10° 斜线1435.335 km30229.783 m默认那两段上一百米上下的偏差在球上确实看不出来。但只要你画的线超过 1°——一条从北京到天津的剖面线就差不多 1.2°——偏差立刻进入米级到了省级跨度5°~10°剖面采的点和球上红线的最远处能差 30 公里。那时候你指着球上那条红线说我要看这条线的剖面实际上切的是另一条山脊。核心优势纯前端闭环影像、地形、采样、绘图全在浏览器里没有任何服务端依赖三种采样口径等步数 / 等间距 / 等点数分别对应每段都要看细、“我要固定物理间距”、“我只想要 N 个点”️手绘即算ScreenSpaceEventHandler收LEFT_CLICK攒折点RIGHT_CLICK或LEFT_DOUBLE_CLICK收尾边画边看图随线动剖面图和球上红线共用同一份路径数据改折点两边一起变五、小贴士避坑指南 关于长度haversine 用的是平均半径haversineMeters里R 6371008.8IUGG 平均半径而 Cesium 的Ellipsoid.WGS84是椭球。两者在同一段上的差不是常数符号是会互相抵消的——默认路径三段实测段 1haversine 比测地线少 62.719 m−0.0856 %段 2少 59.448 m−0.0813 %段 3多 192.929 m0.2035 %——因为它是纯纬线纬圈半径在 lat 31.5° 上约为 95 km/°平均半径算不出来这一段合计haversine241262.471 m测地线241333.234 m短 70.763 m⚠️ 重要提示三项误差恰好抵消掉一大半纯属这条路径运气好。别把这次对了当成这个公式对。要跟 Cesium 内部完全一致直接用EllipsoidGeodesic.surfaceDistanceconstgnewEllipsoidGeodesic(Cartographic.fromDegrees(a[0],a[1]),Cartographic.fromDegrees(b[0],b[1]))constmetersg.surfaceDistance// Cesium 自己就是这么算的 关于横轴interval是跳过几个axisLabel.interval的语义容易反着记写n表示每 n1 个刻度显示一个。demo 写的是Math.max(1, Math.floor(点数 / 6))实测在 20 ~ 1000 个点上显示出来的标签永远是 6~7 个showMinLabel和showMaxLabel还会各补一个。⚠️ 重要提示想让横轴真的是距离轴把xAxis.type换成valuedata换成[[距离, 高程], ...]的点对再把boundaryGap/interval一起删掉。收益是坡度立刻变真代价是点密的地方会被挤在一起——而这恰恰是真实情况。⛰️ 关于高程先确认它真的取到了剖面图最怕的不是不准是静默地不准。getHeight返回undefined时?? 0会给你一条 0 米高的假平台而try / catch连异常都吞了。⚠️ 重要提示把undefined和真的 0 米分开报出来。最小改动就是if (h undefined) missed然后把?? 0留在后面最后在面板上加一行缺失点 N 个。顺带提醒分析前先把相机飞到路径上方、等它加载完。getHeight只认已经渲染出网格的瓦片相机没看过的地方就是undefined。⚡ 关于采样密不等于准Cesium World Terrain 的CesiumTerrainProvider默认用GeographicTilingScheme源码 L186level L 的瓦片经纬度跨度都是180 / 2^L度层级角度跨度纬向边长经向边长lat 31°100.175781°19489.0 m16787.9 m110.087891°9744.5 m8393.9 m120.043945°4872.2 m4197.0 m而采样点间距实测每段步数 300 → 268.07 m固定间距 100 m 档 → 100.03 m。这些点里绝大多数两两落在同一个三角形内部取到的是同一张平面上的线性插值。⚠️ 重要提示与其把采样点加密不如先把相机拉近、让瓦片加载到更细的一层——那才真的增加了信息。采样密度真正解决的是另一个问题**别把山脊采漏。**本猿用一条构造的地形山脊半宽约 2412 m做了 40 次相位扫描同一个山脊不同采样间距能踩到的最坏情况是采样点数采样间距最坏漏掉的山脊高度2012698.0 m725.08 m1002437.0 m564.04 m301804.2 m94.88 m901268.1 m15.78 m同一个山头采样点少的时候能不能看见它全看它是不是恰好落在某个采样点上。 关于几个上限Math.max(...heights)的实参上限实测在本机 V8 上是124000 ~ 125000超了直接RangeError: Maximum call stack size exceeded。这个 demo 的最坏情况是每段步数 300 415 个折点日常够用但如果哪天把上限放开记得改成reduce。固定间距没有点数上限间距滑到最小 100 m一条 241 km 的路径就是 2413 个点、2413 次同步射线求交。Cartographic.fromDegrees本身的构造成本只有0.3729 ms2413 次已预热但getHeight才是大头——而且它是同步的全压在主线程上。密化数组只跟折点数有关1 500 × (N−1)。手绘 20 个折点就是 9501 个对象跟路径多长无关。目前的局限与后续可以做的采样循环是同步for采样点上千时会卡一下 UI——按 200 个一批切开或挪进 WorkerDENSE_PER_SEGMENT 500写死——应该按段长自适应ceil(段长 / 目标间距)累积距离和位置用了两套算法——统一成EllipsoidGeodesic就干净了横轴是类别轴——换type: value才是真距离轴缺采样点没有上报——undefined和 0 米现在长得一模一样只能画一条剖面——没法两条线对比填挖方六、写在最后剖面分析这件事做久了会有一种错觉/ 以为自己切开的是地形/ 其实切开的是这个引擎的一层层抽象——/ 你手里那张曲线只是抽象漏下来的一点影子。从 #53 把点变成清单到 #54 把地挖出一个洞再到今天顺着一条线把地形切开——空间分析这条线终于从看走到了量。现在Cesium 不仅能告诉你那里有多高还能告诉你这一路是怎么爬上来的了。虽然它爬的可能是另一条山脊。这个案例还不完美横轴是类别轴、密化常数写死、缺采样点静默变 0、长线上红线和采样线各走各的。但它把一条剖面是怎么拼出来的这件事讲清楚了这就够了。这条线还能继续往下切。下一次我们不停在地表上了——站在一个点上往四周看哪些地方看得见哪些地方被挡着。通视分析我们下一篇见。如果你在自己的项目里也画过这种看着对、量着歪的剖面图欢迎留言说说你最后是怎么校准的——是靠改用数值轴还是干脆在服务端算完再丢给前端画。 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/profile-analysis/目录下是一个独立的 Vue 单文件组件复制进你的项目、把 Bing 影像换成自己的底图就能跑。Cesium酱の百宝箱 · 第 55 篇红线绕球走大圆剖面按格子走直线2026 年的秋夜与你共探一条线的高程