ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# 二次开发 TerraExplorer 三维 GIS 宿主嵌入与避坑

C# 二次开发 TerraExplorer 三维 GIS 宿主嵌入与避坑

简介:面向具备一定C#与GIS基础、希望基于Skyline TerraExplorer进行三维地球二次开发的开发者,这份示例代码包提供可运行的WinForms工程骨架与接口调用范例,帮助理解地图控件、事件响应、图层与三维模型加载、空间查询及自定义界面等常见任务的落地方式。压缩包共21个文件、约42KB,以C#源码(.cs)和窗体资源(.resx)为主体,并附带Shapefile(.shp/.dbf/.prj/.shx等)与Fly工程文件,便于对照代码验证数据加载和场景组织流程,工程还包含解决方案与项目配置,适合直接打开调试。已有484人学习下载,可作为上手TerraExplorer SDK、梳理二次开发思路和搭建演示原型的参考材料。

1. 从一块怎么也没法嵌进去的三维地球说起

去年帮人接手一个桌面端三维 GIS 项目,难点不在业务算法,而在把一个三维地球塞进自己的 WinForms 窗体:地球能弹出来,却像一个独立窗口浮在面板上,拖动主窗体时它就停在原地。这类需求在三维 GIS 圈子里很典型,业内常笼统地把这套产品线叫作 Skyline 三维客户端,核心组件就是 TerraExplorer。这份 C# 二次开发示例代码,拆开的是宿主嵌入、工程加载、视点飞行、对象标绘、插件消息这几条主线。适合两类人:要把三维场景接进已有桌面业务系统的开发者,以及刚拿到 TE 组件、对着接口文档不知从哪下手的人。

2. 项目定位与目录结构:这套 C# 示例给的是哪几块拼图

TerraExplorer 本质是一个 COM 服务器。它自己有一个完整的桌面客户端,能打开.fly工程文件,工程里装着图层树、三维对象、视点、场景参数和渲染设置。二次开发做的不是重写渲染,而是通过 COM 接口去驱动这个已经做好的客户端:让它在你的窗口里显示,让它飞到某个坐标,让它画一个点,让它在你点击时回调你。C# 示例代码的价值就在这儿——它把“怎么驱动”翻译成了可以直接编译运行的小工程。

2.1 一份示例包通常长什么样:宿主、插件、脚本三条支线

从这类示例包的常见组织方式看,代码一般分成三条支线,各管一件事:

  • 宿主支线:WinForms 或 WPF 工程,负责创建进程内的 TerraExplorer 实例、把渲染窗口挂到自己的 Panel 上、管理工程打开与关闭。这条线是绝大多数人真正要抄的部分。
  • 插件支线:编成一个 DLL,由 TE 客户端在启动时加载,跑在客户端进程里。适合做“客户端里加一个自己的按钮/面板”的需求,也适合做和宿主通信的功能扩展。
  • 脚本/批处理支线:控制台小工具,不显示界面,只做无人值守的活,比如批量把一堆坐标导成三维标注、批量把工程里的对象导出成表。

目录上,这三条线通常平铺在一个解决方案下,公共的接口封装放在一个类库工程里被前两者引用。核心目录大致是这种结构:

TeDemo.sln ├── TeDemo.Host/ WinForms 宿主,主入口 │ ├── MainForm.cs 创建实例、SetParent、打开工程 │ └── ViewerPanel.cs 渲染窗口的容器与尺寸同步 ├── TeDemo.Plugin/ TE 插件 DLL │ ├── PluginEntry.cs 实现插件接口,接收工程事件 │ └── MessageBridge.cs 与宿主之间的自定义消息收发 ├── TeDemo.Core/ 公共封装 │ ├── SceneService.cs 对 ITerraExplorer/IProject/INavigate 等的门面 │ └── CoordHelper.cs 经纬度与本地坐标互转 └── TeDemo.Tools/ 控制台批处理 └── BatchExport.cs

值得先说清楚的是.fly工程文件本身。它是整个项目的“配置文件 + 数据索引”:既存了场景设置,也存了每个三维对象的描述、样式和挂载的业务数据。很多人第一次接手时以为工程文件可以随便换机器拷,结果拷过去一片空白——因为里面引用的图层源地址往往是绝对路径或者某个服务地址,这部分后面在避坑章会专门说。

2.2 为什么是 C# 加 COM 互操作,而不是别的组合

TE 对外只暴露 COM。这意味着可选路线其实就三条:C#/VB.NET 走 COM 互操作、C++ 直接吃 COM、以及网页里嵌 ActiveX 控件。C# 被选得多,原因很实际:COM 互操作由运行时兜底,Visual Studio 生成 interop 程序集之后,接口能直接被智能提示认出来,事件也能用+=挂,写起来比 C++ 少一大截样板代码;而 ActiveX 那条路绑死在旧浏览器内核上,早就不是新项目该考虑的方向。

代价是,COM 带来的坑一个不少:线程模型是 STA,对象释放必须严格按顺序,位数必须对齐。这也是示例代码里为什么频繁出现Marshal.ReleaseComObject和[STAThread]——它们承担的作用是“别让程序崩在退出那一步”,不是为了好看。

2.3 一次会话里只有一个实例:接口聚合模型

这是 TE 二次开发里最容易懵、也最该早点想明白的一点:你new出来的那个TerraExplorer对象,其实是同一个 COM 对象的多套接口视图。工程加载、视点飞行、对象创建、坐标转换,用的是同一个对象、不同的接口,而不是四个独立的东西。

// 一个 COM 实例,多套接口,全部从同一个对象上转出来 var te = new TerraExplorerX.TerraExplorer(); var project = (IProject86)te; // 工程:打开/关闭/保存 var navigate = (INavigate66)te; // 导航:飞行/取当前位置 var creator = (ICreator66)te; // 创建:点/线/面/模型 var coordSys = (ICoordSys66)te; // 坐标:经纬度与本地坐标互转 var terrain = (ITerrain66)te; // 地形:取地面高度 var infoTree = (IInformationTree66)te;// 信息树:图层树显隐与操作

接口名后面那串数字是版本号后缀,56 对应 5.6,66 对应 6.6,76、86 依次类推。同一套逻辑在不同大版本之间,方法名和参数顺序可能差一两个,这不是你写错了,而是 interop 跟着组件版本变了。我一般的做法是:先把版本对应的接口号在一个地方统一定义成别名,业务代码只用别名,将来换版本只改一处。

另外一个隐含约束是,这些接口背后是进程内的一个渲染引擎。所有对它的调用默认都该在 UI 线程上发生,别丢到Task.Run里去。后面讲事件回调时会看到,这条约束决定了你怎么把回调数据送回界面。

3. 跑通第一帧:环境准备与宿主窗体的最小闭环

示例代码里最值得先跑通的就是“宿主窗体”这一条线,因为它一次性把环境、位数、嵌入、加载、释放五个问题全暴露出来。跑通了它,后面所有功能都只是在这上面加调用。

3.1 装什么、注册什么、引什么

前置条件其实不多,但要一一对上:

  1. 本机装好 TE 客户端运行时(示例包依赖它注册的 COM 组件和一堆 DLL)。只装运行库也够跑宿主,但要打开别人做的工程,最好装同版本的完整客户端。
  2. 确认组件已注册。注册表里能查到对应的 ProgID,才说明new TerraExplorer()不会抛异常。
  3. 在 Visual Studio 里加 COM 引用,或者直接引用安装目录下的 interop 程序集,生成TerraExplorerX这个命名空间。
  4. 把工程的目标平台从 AnyCPU 改成 x86。

第 4 条是血泪经验。TE 的 COM 组件长期是 32 位的,如果你的进程跑在 64 位下,new那一步就会直接抛0x80040154(类未注册)。报错信息说的是“类未注册”,实际原因却是位数不匹配,很容易被带偏。改法就一行:项目属性 → 生成 → 平台目标选 x86,别用 AnyCPU。

<!-- csproj 里把平台钉死,避免同事拉下来又改回 AnyCPU --> <PropertyGroup> <PlatformTarget>x86</PlatformTarget> <Prefer32Bit>false</Prefer32Bit> </PropertyGroup>

Prefer32Bit保持 false 是为了让 x86 的语义明确,不要出现“看着是 x86、跑起来还是 64 位宿主进程”的玄学情况。

3.2 把三维窗口塞进 WinForms 面板

嵌入的原理只有一句:TE 在创建时会生成一个自己的窗口,你把它的父窗口改成你的 Panel,它就落在你的界面里了。

using System; using System.Windows.Forms; using System.Runtime.InteropServices; using TerraExplorerX; public partial class MainForm : Form { private TerraExplorer te; // COM 聚合对象,全进程只留一个 [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetParent(IntPtr hWndChild, IntPtr hWndNewParent); [DllImport("user32.dll")] private static extern bool MoveWindow(IntPtr hWnd, int x, int y, int width, int height, bool repaint); private void InitViewer() { te = new TerraExplorer(); // 起渲染引擎 var viewer = (ITerraExplorer56)te; IntPtr child = (IntPtr)viewer.GetWindowHandle(); // 拿到它自己的窗口句柄 SetParent(child, panelViewer.Handle); // 改父窗口,落进面板 ResizeViewer(); // 尺寸对齐 } private void ResizeViewer() { var viewer = (ITerraExplorer56)te; IntPtr child = (IntPtr)viewer.GetWindowHandle(); MoveWindow(child, 0, 0, panelViewer.Width, panelViewer.Height, true); } // 面板尺寸变化时同步,否则会出现黑边或画面被裁 private void panelViewer_Resize(object sender, EventArgs e) => ResizeViewer(); }

这段代码有三个要点。new TerraExplorer()会启动渲染引擎并创建窗口,这一步耗时明显,别放在窗体构造里,放到Load里更稳。GetWindowHandle()返回的是原生句柄,转成IntPtr才能给SetParent用。MoveWindow必须跟着 Panel 的尺寸走,否则窗口只在首次嵌入时正确,用户一拉大窗体,三维画面就停在小尺寸上,看起来像“卡住了”,其实只是没人通知它改大小。

3.3 打开工程与退出时的释放顺序

打开工程和关闭工程,是这套代码里顺序性最强的一段。顺序错了,轻则退出时报“COM 对象与其基础 RCW 分开后就不能再使用”,重则进程残留,任务管理器里挂着一个不退的进程。

private IProject86 project; private void OpenProject(string flyPath) { project = (IProject86)te; // 第二个参数是工程密码,没有就传空串 project.Open(flyPath, ""); } private void CloseProject() { if (project != null) { project.Close(); // 1. 先关工程 Marshal.ReleaseComObject(project); // 2. 释放接口引用 project = null; } if (te != null) { Marshal.ReleaseComObject(te); // 3. 最后放主对象 te = null; } GC.Collect(); // 4. 促一把回收 GC.WaitForPendingFinalizers(); }

为什么是这个顺序:te是所有接口的源头,project是从它上面转出来的视图。先放源头再放视图,运行时就会认为你还在用这个已经拆掉的对象。反过来,先关工程、再放视图、最后放源头,引用计数才是一路降到底。GC.Collect那两行不是迷信,是给 COM 的终结器一个执行机会,尤其在窗体关闭事件里调用时能明显减少残留。

还有一条容易被漏掉:如果挂了事件(下一章会讲),关闭前必须先解绑。事件不解绑等于对象还被你引用着,引用计数降不下去,进程自然退不掉。

4. 核心模块拆解:视点、对象、事件、坐标四条主线的实现

4.1 视点飞行:FlyTo 的参数与漫游节奏

视点控制是示例里调用频率最高的功能。业务上的说法是“飞到某栋楼”“飞到某个点位”,落到接口上就是一次FlyTo。

var navigate = (INavigate66)te; // FlyTo(高度参考方式, 经度, 纬度, 高度, 偏航角, 俯仰角, 飞行秒数) // 高度参考方式决定“高度”是相对地面还是绝对海拔 navigate.FlyTo(AltitudeTypeCode.ATC_TERRAIN_RELATIVE, 116.397, 39.908, // 目标经纬度 800, // 相对地面 800 米 0, // 偏航 0 度,正北 -45, // 俯仰 -45 度,向下看 3.0); // 3 秒飞到位

参数里最容易被误用的是第四个和第七个。高度值配合高度参考方式才有意义,用相对地面却填了海拔数值,飞过去会发现镜头扎进地里或者飘在半空。飞行秒数填 0 是瞬移,填太小会有明显的“甩镜头”感,一般 2~4 秒观感比较自然。俯仰角为负是俯视,为正是仰视,很多人第一次填正数,结果镜头对着天空。

批量漫游不要用Thread.Sleep串起来,那样界面会僵住。常见做法是用一个Timer按固定间隔推进到下一个点,或者监听渲染帧事件,等上一次飞行结束后再发下一次。这个细节决定了漫游看起来是顺滑的还是抽搐的。

取当前位置反而是同步的:

double x, y, height, yaw, pitch; navigate.GetPosition(out x, out y, out height, out yaw, out pitch);

注意这里拿出来的x、y是工程当前坐标系下的坐标,不一定是经纬度。工程如果配的是平面坐标,你得靠下一节的坐标转换接口再换一次。这是新手最容易踩的一个坑:拿着平面坐标当经纬度去填FlyTo,飞到一个完全无关的地方。

4.2 对象创建与业务 ID 挂载

三维标绘的价值不在于画出那个点,而在于“画出来的东西能跟业务数据对上”。TE 的对象接口提供了一个专门挂业务数据的位置,习惯上叫 ClientData。

var creator = (ICreator66)te; ITerraExplorerObject66 obj; // CreatePoint(经度, 纬度, 高度, 样式ID, 分组, 描述, 业务数据, 插入位置, out 对象) creator.CreatePoint(116.397, 39.908, 0, 0, // 点样式 ID,0 用默认样式 "monitor-group", // 分组,方便按组批量删 "监控点位A", // 鼠标悬停时显示的描述 "P0001", // 业务主键,关键就在这一位 -1, // 插入到信息树的末尾 out obj); // 之后可以按业务主键反查、改样式、隐藏 obj.Visible = true;

ClientData那一位填业务主键,是整个二次开发里最划算的一个约定。因为三维对象本身只有内部 ID,业务系统要的是自己的主键,两边对不上,后续所有交互都得靠坐标比对,极其脆弱。把主键塞进ClientData,之后无论是点击回调还是批量更新,都能一步定位到业务实体。分组字段也值得用起来,按业务类型分组,删除时整组清掉,比遍历删除快得多,也不容易漏。

创建模型走的是另一个方法,传的是模型文件路径,而不是经纬度:

creator.Create3DObject(@"D:\GisData\models\tower.xpl", 116.397, 39.908, 0, 0, "tower-group", "信号塔", "T0007", -1, out obj);

模型格式和贴图路径的兼容性这里有个隐形坑:从别的机器拷过来的模型,贴图经常丢,表现为模型一片白。这类问题不是代码问题,检查模型的贴图引用是不是用的相对路径就行。

4.3 事件回调与 Popup 消息中转

没有事件的二次开发只能单向驱动,用户点了什么你全不知道。TE 的鼠标事件挂在工程接口上,点击之后拿到的还是屏幕坐标,得再转成世界坐标。

private void HookEvents() { project.OnLButtonClick += Project_OnLButtonClick; } private void Project_OnLButtonClick(int screenX, int screenY) { // 屏幕坐标转经纬度,方法名在不同版本里略有差异,认准带 Screen 的那个 double lon, lat, alt; ((ITerraExplorer56)te).GetWorldPointFromScreen(screenX, screenY, out lon, out lat, out alt); // 回调发生在渲染线程侧,改界面必须投递回 UI 线程 BeginInvoke(new Action(() => { lblCoord.Text = $"{lon:F6}, {lat:F6}, {alt:F1}"; })); }

两点必须强调。第一,屏幕坐标转世界坐标这个方法,5.x 和 6.x 的命名不一样,别硬背,在对象浏览器里搜带 Screen 的方法最快。第二,回调里直接操作控件很容易翻车——看起来能跑,偶尔卡死,而且极难复现。统一用BeginInvoke投回主线程,这条规矩越早定下来越好。

宿主和插件之间的通信,常见做法是走自定义消息通道,两边约定好消息类型和字符串载荷格式:

// 插件侧:往宿主发一条消息,类型码自己约定,载荷建议用 JSON var msg = (ITerraExplorerMessage66)te; msg.AddMessage(1001, "{\"cmd\":\"locate\",\"id\":\"P0001\"}"); // 宿主侧:接收并解析 ((ITerraExplorerMessage66)te).OnMessage += (type, payload) => { if (type == 1001) { // 解析 payload,定位到对应业务对象 } };

消息载荷用 JSON 而不是拼字符串,看起来多写几行,实际上省下了后面所有的解析兼容工作。

4.4 坐标转换与拾取高度

坐标系是三维项目里最不显眼、出错最难查的一块。工程可能配的是地理坐标,也可能是某个平面坐标系,而业务系统给的往往是经纬度。

var coordSys = (ICoordSys66)te; // 经纬度 -> 工程本地坐标 double x, y; coordSys.WGS84ToLocal(116.397, 39.908, out x, out y); // 工程本地坐标 -> 经纬度 double lon, lat; coordSys.LocalToWGS84(x, y, out lon, out lat); // 取某个位置的地面高度,用于把对象贴地 var terrain = (ITerrain66)te; double groundHeight; terrain.GetGroundHeight(lon, lat, out groundHeight);

GetGroundHeight的返回值依赖地形数据是否已经加载完。工程刚打开时立刻调用,拿到的可能是 0 或者上一次的缓存值。稳妥的做法是等渲染事件跑过一两帧之后再取,或者对结果为 0 的情况做一次兜底。

5. 避坑与排查:TE 二次开发里最耗时间的五种现场

5.1 现场一:类未注册,报 0x80040154

现象:代码编译通过,运行时在new TerraExplorer()抛异常,提示类未注册。

原因:九成是位数不匹配。TE 的 COM 组件是 32 位的,而你的进程在 AnyCPU 下默认跑成了 64 位。少数情况是真的没装运行库,注册表里查不到 ProgID。

解决:先把平台目标改成 x86 再跑一次,这一步能解决绝大多数同类报错。改完还不行,就去注册表确认组件是否注册,再确认安装的是不是同一大版本。别一上来就怀疑代码,这个错跟代码基本无关。

5.2 现场二:三维画面浮在面板上,拖动主窗体时原地不动

现象:初始化时画面位置看着是对的,但主窗体一移动,三维画面就脱离面板留在屏幕原位。

原因:只调了一次SetParent,没有处理后续的窗口位置同步。TE 渲染窗口有自己的一套位置管理逻辑,父窗口移动不会自动传导过去。

解决:在播放器面板的Resize和窗体的Move事件里都调一次MoveWindow,把位置和尺寸重新对齐。如果画面还有黑边,检查是不是把SetParent的目标设成了窗体本身而不是面板,挂错父窗口时布局系统管不到它。

5.3 现场三:退出后进程残留,或报 RCW 分离错误

现象:关闭窗体后任务管理器里还挂着一个进程;或者反过来,退出时抛“COM 对象与其基础 RCW 分开后就不能再使用”。

原因:释放顺序错了,或者挂了事件没解绑。前者是对象依赖关系被倒着拆,后者是引用计数始终降不到零。

解决:按“解绑事件 → 关工程 → 放接口引用 → 放主对象 → 促回收”这个顺序走。事件的解绑容易被忘,写释放逻辑时把它当成第一步固定下来,之后就不用每次想。

5.4 现场四:工程换台机器打开就空白

现象:同样的.fly文件,在开发机上一切正常,拷到另一台机器打开,图层树在但画面空,或者直接报加载失败。

原因:工程里引用的数据源地址是绝对路径或固定服务地址,换机器后路径不存在。另一种情况是两边的运行库版本不一致,部分图层格式读不出来。

解决:把工程用到的数据源统一放到相对路径下,工程和数据一起拷;图层走服务的,确认目标机器能访问到服务。版本上也别凑合,开发机和部署机用同一个大版本,能省掉大量“看起来是代码问题其实是环境问题”的排查。

5.5 现场五:点击回调里改界面,偶发卡死

现象:点三维场景,界面偶尔僵住几秒,日志里没有异常,重启后又正常。

原因:回调发生在渲染线程侧,你在里面直接碰了 WinForms 控件。跨线程操作控件在某些时序下会死锁,表现就是偶发卡顿而不是必崩,所以特别难查。

解决:回调里只做数据提取,所有界面更新一律BeginInvoke投回主线程。这条规矩建议写进团队的代码规范里,比事后一个个查便宜得多。顺带一提,FlyTo之后立刻GetPosition拿到的是旧值,渲染是异步的,想拿新位置就等一帧再读。

6. 二次开发改造:把散落的调用收进一层服务门面

示例代码为了把接口讲清楚,通常会在窗体里直接(INavigate66)te这么转来转去。真做项目时这么写会有两个后果:窗体代码越滚越大,以及换版本时满项目找56、66这些后缀。我的做法是先抽一层接口,业务只认这层。

public interface ISceneService { void OpenProject(string flyPath); void FlyToPoint(double lon, double lat, double height, double seconds); string AddMarker(double lon, double lat, string title, string bizId); void Close(); }

实现类内部把IProject86、INavigate66、ICreator66这些一次性转好存成字段,对外只暴露经纬度和业务 ID。好处很实际:换大版本时只改这一个类里的接口号;单元测试时可以拿一个假实现顶替,不用起渲染引擎就能测业务分支;窗体那边只剩事件绑定和界面更新两件事,读起来清爽。

改造完拿一份清单回归一遍,比凭感觉点几下靠谱:

验证项判据常见失败原因
实例创建不抛 0x80040154平台目标不是 x86
窗口嵌入拖动窗体画面跟随未在 Move/Resize 里同步位置
工程加载图层树与画面一致数据源路径或版本不一致
视点飞行目标点居中且高度合理高度参考方式与数值不匹配
标绘回查按业务 ID 能定位到对象ClientData 未写入或写入被覆盖
退出释放进程完全退出事件未解绑或释放顺序错

还有一个小技巧值得单独说:把ClientData的写入和读取封装成两个方法,读的时候做一次空值校验。因为对象一旦被外部工程改动过,业务数据可能被清空,不校验的话,反查会静默失败,表现为“点了没反应”,查起来非常费时间。

从那以后我每接一个 TE 项目,都先把平台目标锁成 x86、把释放顺序写进基类、把所有界面更新强制走BeginInvoke,这三件事做完再动业务代码。吃过一次退出时进程不退的亏,排查了两天才发现是事件忘了解绑,现在这套流程已经成了肌肉记忆。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进