TerraExplorer二次开发:C# WinForms嵌入三维地球

发布时间:2026/11/2 2:07:03
TerraExplorer二次开发:C# WinForms嵌入三维地球

简介:面向GIS与三维地球可视化开发者的TerraExplorer二次开发C#示例代码,基于Skyline平台SDK,适合已具备C#与面向对象基础、希望将三维地图能力嵌入桌面应用的中级开发者。示例围绕地图对象与控件调用、事件响应、Shapefile等空间数据加载、3D模型与场景组织、空间查询分析、自定义界面以及多线程与调试优化等任务展开,便于理解API调用顺序、对象职责划分和工程组织方式。压缩包共21个文件,约42KB,以cs源码和sln/csproj工程文件为主体,配合resx资源文件及shp、shx、dbf、prj、qix等Shapefile配套数据,另有fly飞行文件与少量辅助配置,打开解决方案即可对照学习。已有484人学习,适合用来搭建TerraExplorer二次开发的知识框架,并在此基础上做功能扩展、界面调整和调试练习。对需要快速定位SDK接口、复用工程骨架的读者较为友好。

1. TerraExplorer 二次开发:把三维地球塞进 WinForms 的最小闭环

接过一个三维巡检调度系统的活儿,需求朴素得不像三维项目:左边业务表格点一条记录,右边地球就飞到那根杆塔上。三维那半边用的是 skyline 系的 TerraExplorer,桌面端暴露一套 COM 接口,C# 靠互操作直接调。真上手会发现,难的从来不是写几行 FlyTo,而是让这套 COM 在你机器上被正确注册、位数对得上、接口版本强转得过去。这份 C# 示例代码解决的就是这件事:从窗体里塞进一个三维地球,到加载 .fly 工程、视点飞行、地形打点、事件回调,把整条最小闭环跑通。适合手上已经有 TE 工程文件、需要把三维场景嵌进自家 WinForms/WPF 业务系统的开发者,也适合第一次接触 COM 互操作、想找个能编译运行的骨架照着改的人。

2. 环境与工程搭建:COM 注册、位数对齐和 csproj 里那三处必改项

第一次跑这类示例,十个里八个卡在同一个地方:引用加了,代码抄了,new TerraExplorerX.TerraExplorer()一执行就抛异常。三维桌面端的 C# 二次开发骨子里是托管代码调 COM,托管那一侧只负责语法糖,真正的坑全在注册表和编译选项里。这一章把"能编译、能 new 出来、能显示"这三步拆开讲。

2.1 装完之后,真正被调用的只有那几个 COM 组件

安装包里东西不少:主程序、地形打包工具、示例工程、一堆原生 DLL。但 C# 侧真正打交道的只有一个注册到系统的 ActiveX 控件;工程里using TerraExplorerX;引的那份Interop.TerraExplorerX.dll,是 VS 根据类型库生成的互操作程序集,和安装目录里的原生 DLL 不是一回事。前者是给托管代码看的壳,后者是 C++ 写的实体。

这个区别在部署时特别要命。给客户机发布程序,你要带的是互操作程序集加目标机上的 COM 注册,而不是把安装目录整个拷过去。反过来,开发机上"我这能跑",到客户机上白屏,八成也是这个原因。

怀疑注册没成功,先别急着翻代码,开个命令行查注册表:

# ProgID 一般是 TerraExplorerX.TerraExplorer 这种形式,不同版本可能带后缀 reg query "HKCR\TerraExplorerX.TerraExplorer" /s # 32 位组件常被重定向到 WOW6432Node 下,64 位落在原生节点 reg query "HKLM\SOFTWARE\WOW6432Node\Classes\TerraExplorerX.TerraExplorer" /s

能查出来说明 ProgID 注册上了,查不到就是没装、装坏或者位数不对。位数这件事得说透:TE 有 32 位和 64 位两套,注册位置不同。你的程序编译成 x86,却只装了 64 位那套,new的时候照样报"类未注册"——因为 32 位进程只会去 32 位节点找。我一般先看一眼安装目录和任务管理器里主程序的位数,再决定 csproj 里写 x86 还是 x64,顺序反过来会白折腾一下午。

2.2 拖控件还是代码创建:两种宿主方式的取舍

把地球放进窗体有两条路。第一条是设计器路线:工具箱右键"选择项"→COM 组件→勾上 TerraExplorerX,拖到窗体上,属性面板里还能可视化绑事件。优点是所见即所得,缺点是设计器加载 COM 控件本身就很脆,VS 版本一换就打不开窗体设计器;团队里有人没装 TE,拉下代码连窗体都显示不出来,只能对着.Designer.cs干瞪眼。

第二条是代码创建,我更推荐,尤其是多人协作的项目:

using TerraExplorerX; // COM 互操作命名空间,由类型库生成 public partial class SceneForm : Form { private TerraExplorerX.TerraExplorer _teControl; private void BuildSceneHost() { // 必须在 UI 线程构造,COM 控件有线程关联性 _teControl = new TerraExplorerX.TerraExplorer { Dock = DockStyle.Fill }; // 左侧留给业务树,右侧放三维 splitContainer1.Panel2.Controls.Add(_teControl); } }

Dock = DockStyle.Fill让控件跟着面板拉伸,别用 Anchor 四边固定,缩放时三维视口会抖。Controls.Add这一步不能省,构造函数里只是把 COM 对象创建出来,没有父容器它不会显示。另外窗体关闭时记得在Dispose里把_teControl也释放掉,否则进程退了 COM 还挂着,下次启动偶尔会拿到一个僵尸实例。

WPF 项目走的是同一条路,用WindowsFormsHost包一层:

// WPF 里宿主 WinForms 控件,注意 Airspace 问题:三维区域上面盖不住 WPF 控件 var host = new WindowsFormsHost(); host.Child = new TerraExplorerX.TerraExplorer { Dock = DockStyle.Fill }; grid.Children.Add(host);

这里的取舍很现实:如果你的界面要在三维球体上方浮一层半透明面板,WinForms 宿主做不到,得改成纯接口方式拿到Interface自己渲染,或者干脆把业务面板放到侧边栏。这个限制在项目初期不提,后期改起来伤筋动骨。

2.3 csproj 里改三处:PlatformTarget、EmbedInteropTypes、CopyLocal

代码能编译但运行报错,多半是这三处没改。Visual Studio 加 COM 引用时给的默认值,对版本化 COM 接口来说是不合适的:

<PropertyGroup> <!-- 与已安装的 TerraExplorer 位数保持一致:装 64 位写 x64,装 32 位写 x86 --> <PlatformTarget>x64</PlatformTarget> <Prefer32Bit>false</Prefer32Bit> </PropertyGroup> <ItemGroup> <Reference Include="Interop.TerraExplorerX"> <!-- 必须关掉:开着会把版本化接口裁剪掉,运行时报找不到方法 --> <EmbedInteropTypes>False</EmbedInteropTypes> <CopyLocal>True</CopyLocal> </Reference> </ItemGroup>

PlatformTarget决定进程位数,和 2.1 里说的注册节点一一对应。EmbedInteropTypes是 .NET 4 之后引入的"类型等价"特性,对普通 Office 互操作很香,但 TE 的接口带数字版本后缀、方法重载又多,嵌进去之后编译器往往只保留它认为用得到的那部分,结果就是编译通过、运行时MissingMethodException。CopyLocal保证输出目录里有那份 Interop DLL,缺了它开发机能跑、发布包一拷就崩。

一个血泪经验:Debug 和 Release 是两个独立的配置,改完 Debug 记得切到 Release 再改一遍。我见过不止一个项目在本地调了两天都正常,打包发出去客户机直接闪退,最后发现 Release 配置还停在 AnyCPU。

3. 核心对象模型拆解:一个 FlyTo 请求走过的四层接口

COM 对象的调用链其实很短,短到很多人懒得去理解它,然后就一直在"能跑就行"和"莫名其妙崩了"之间反复横跳。这一章把从控件到相机的那条链路拆开,每一层管什么、边界在哪,讲清楚之后排查问题会快很多。

3.1 Interface 属性与版本化接口:那次强转到底做了什么

控件对象本身没什么业务方法,它的价值在一个属性上:

// Interface 返回 object,运行时是 ITerraExplorerNN 这种带版本后缀的接口实例 ITerraExplorer66 te = (ITerraExplorer66)_teControl.Interface; ICreator66 creator = te.Creator; // 建对象、建位置 INavigate66 navigate = te.Navigate; // 相机运动 IProject66 project = te.Project; // 工程与对象树 ITerrain66 terrain = te.Terrain; // 地形查询

Interface声明成object是 COM 的历史包袱,实际类型是版本化接口。为什么接口名后面挂数字?因为 COM 契约一旦发布就不能改,要加功能只能出新接口,老的留着兼容。所以你强转成哪个后缀,等于声明"我按这一版契约写代码"。

这里有个方向性差异要记住:装新版 TE,老后缀接口通常还在,向后兼容;装老版 TE 却强转新后缀,直接InvalidCastException。项目里我一般只在初始化时强转一次,存成私有字段,其它地方一律用字段,不重复转。重复强转本身不慢,但一旦哪天要升级版本,散落在二十个文件里的强转能让人想砸键盘。

3.2 Creator / Navigate / Project / Terrain 四个入口的分工

入口接口典型用途常用成员
CreatorICreator建对象、建位置、删对象CreatePosition、CreatePoint、DeleteObject
NavigateINavigate相机飞行、跳转、锁定对象FlyTo、JumpTo、FlyToObject
ProjectIProject工程加载保存、对象树管理Open、Save、ObjectManager
TerrainITerrain取地表高程、地形属性GetHeight

Creator是唯一能造出IPosition的地方,IPosition又是Navigate的输入——这条依赖关系决定了调用顺序:先 Creat 出一个位置对象,再交给 Navigate 飞过去。反过来先飞再创建位置没有意义。

Project层管工程生命周期和对象树,做批量操作比如"清空某个分组下的所有标注"就走这里。Terrain用得最多的只有GetHeight(x, y),打点、贴地、算相对高度全靠它。注意它返回的是当前加载地形的高程,工程没加载完就调用会拿到 0 或者异常值,这是个典型的时序坑。

3.3 事件与线程:回调里能干什么、不能干什么

事件必须在工程加载之前绑,这个顺序错了,加载完成的通知就永远收不到:

// 先绑事件,再 Open,顺序反了会漏掉加载完成回调 te.OnLoadProject += OnProjectLoaded; te.OnLButtonClick += OnLeftButtonClick; te.OnFrame += OnFrame; private void OnProjectLoaded(string projectPath) { // 回调来自 COM 的消息泵,此时已经在 UI 线程,可以安全改控件 _ready = true; statusLabel.Text = "工程已加载:" + projectPath; }

关键点在于线程关联性。TE 控件是 STA 的,所有调用必须在创建它的那个线程上发起。很多人写了个Task.Run或者System.Timers.Timer,在里面直接navigate.FlyTo(...),现象是"有时候没反应,有时候整个界面假死"——它不报错,就装死,这是最难查的一类问题。

正确的做法是老老实实回 UI 线程:

// 后台线程算完坐标,把真正的 COM 调用抛回 UI 线程 var pos = await Task.Run(() => ComputeViewpoint(row)); // 纯计算,不碰 COM te.Navigate.FlyTo(pos, ActionCode.AC_FLY); // 回到 UI 线程再调

await在 WinForms/WPF 里默认捕获同步上下文,await 之后的续体会回到 UI 线程,所以上面这段是安全的。如果用的是老式Thread,那就得靠BeginInvoke手工封送。

OnFrame是每帧回调,频率很高。里面只适合做轻量判断,比如检查相机是否到位、更新一下状态栏数字。在里面建对象、改对象树,帧率会肉眼可见地掉下去,这是三维项目里最常见的性能翻车点。

3.4 高度基准与坐标系:飞偏和悬空都出在这儿

CreatePosition的参数里有一个高度类型,它决定了后面那个数字往哪套:

枚举含义什么时候用
AEG_AboveTerrain相对地面高度贴地漫游、杆塔挂点、跟地形走的标注
AEG_AboveSeaLevel相对海平面高度与外部 GPS 数据、航线数据对齐

新手最容易犯的错是混用:视点数据来自外部表格(海拔),代码里却传了AEG_AboveTerrain,结果在平原上看着还行,一到山区整个视角就沉到地底下去了。反过来把贴地标注写成海拔,山上的点会飘在半空。

坐标系同理。工程本身可能是投影坐标系而不是经纬度,这时候传进去的 x、y 数值量级完全不对,飞过去会落在离目标几百公里外的空白区域。判断方法很直接:看一眼数值量级,经纬度是几十到一百多,投影坐标是六位七位。拿不准就先读一个已知点的相机位置,对着它的数值反推工程用的哪套。

4. 三个能直接抄的示例:加载工程、视点飞行、地形打点

提示:下面几段按接口语义写骨架,不同 TE 版本里枚举名和方法重载会有出入,编译不过时先把光标停在方法名上看一眼 IntelliSense 的重载列表,函数名本身是稳定的。

4.1 示例一:初始化与加载 .fly 工程

加载是最基础也最容易漏兜底的一步。工程文件可能不存在、可能引用了本地绝对路径的模型资源、可能版本比运行环境新:

private ITerraExplorer66 _te; private bool _ready; private void InitScene(string flyPath) { if (!File.Exists(flyPath)) throw new FileNotFoundException("工程文件不存在", flyPath); _te = (ITerraExplorer66)_teControl.Interface; // 事件必须先绑,Open 过程中的加载完成通知才不会漏 _te.OnLoadProject += path => { _ready = true; statusLabel.Text = "工程已加载:" + Path.GetFileName(path); }; // 第二个参数是工程密码,没有就传空字符串 _te.Open(flyPath, string.Empty, OpenType.OT_NORMAL); }

_ready这个标志位很关键。Open是异步的,方法返回不代表工程可用,这中间有几百毫秒到几秒的窗口期。所有依赖工程的调用(打点、飞行、GetHeight)都得先判断_ready,否则就是那个经典现象:程序启动瞬间点了按钮,标注飞到不知道哪里去了。

工程路径我一般走配置文件,不硬编码。三维工程的资源路径经常是绝对路径,换台机器就断链,所以发布前要确认工程本身是"打包模式"还是"引用本地文件模式"。

4.2 示例二:视点数组驱动的巡检飞行

单次FlyTo太简单,实际项目都是按路线飞。把视点抽成一个类,路线就能从数据库或 JSON 读:

public class Viewpoint { public double Lon { get; set; } // 经度 public double Lat { get; set; } // 纬度 public double Alt { get; set; } // 视点高度,米 public double Yaw { get; set; } // 偏航角,0 为正北,顺时针为正 public double Pitch { get; set; } // 俯仰角,负值表示向下俯视 public double Roll { get; set; } // 翻滚角,一般留 0 public double Range { get; set; } // 相机到目标点的距离 public int StayMs { get; set; } // 到位后停留时长 } private async Task FlyRouteAsync(List<Viewpoint> route, CancellationToken ct) { foreach (var vp in route) { ct.ThrowIfCancellationRequested(); if (!_ready) continue; IPosition pos = _te.Creator.CreatePosition( vp.Lon, vp.Lat, AltitudeTypeCode.AEG_AboveSeaLevel, vp.Alt, vp.Yaw, vp.Pitch, vp.Roll, vp.Range); _te.Navigate.FlyTo(pos, ActionCode.AC_FLY); // 简单做法是固定延时排队,讲究一点就去 OnFrame 里判断相机是否到位 await Task.Delay(vp.StayMs, ct); } }

Yaw和Pitch的符号约定要拿实际工程校准一遍,不同底图和数据源方向定义可能有偏差,别照抄。Range是相机到目标的距离,把它理解成"镜头拉多远"就行,值越小越贴近目标。

ActionCode.AC_FLY走的是动画飞行,AC_JUMP是直接跳过去。巡检演示用飞行好看,批量出图用跳转快得多,这个选择对性能影响不小。

用Task.Delay排队是最朴素的实现,缺点是不精确——工程加载慢或者场景复杂的时候,相机还没到位延时就走完了,下一段路线会打断上一段。要更准就得在OnFrame里比较当前相机位置和目标位置的差值,小于阈值再推进队列。这套逻辑不复杂,但要写在OnFrame里保持轻量,只做数值比较,别在里面创建对象。

4.3 示例三:在地形上打点、飞到对象、按组清理

打点这件事,真正麻烦的不是建对象,是高度:

private readonly Dictionary<string, List<string>> _markerGroups <p> <a href="https://download.csdn.net/download/izhaohe/7703459" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>